【.NET 与树莓派】矩阵按键

时间:2024-02-01 12:46:17

欢迎收看火星卫视,本期节目咱们严重探讨一下矩阵按键。

所谓矩阵按键,就是一个小键盘(其实一块PCB板),上面有几个 Key(开关),你不按下去的时候,电路是断开的,你按下去电路就会接通。至于说有多少个按钮,这个就看人家工厂怎么弄了,多见的有 3×3=9个键的,有 4×4=16个键的。各个按键排列成阵势,所以称矩阵按键(或矩阵开关)。叫法很多,知道是啥玩意儿就行,不必纠结。

先上一张图,以供列位看官鉴赏。

老周家里穷,吃饭成问题,故而买了一块裸板裸键的,连键帽都没有的。有套套的当然好看,但太装13了,这种裸键的多好,拿在手上特别有科技感。

这个矩阵有4行4列,上面标印 16 个按键(从“S1”到“S16”)。

这个模块刚拿到手的时候,你可能会疑惑——尼马,怎么有8个引脚,但没有电源正极(VCC)和负极(GND),怎么接线?这种模块比较特殊,它不用接供电相关的线,从上图咱们看到,这厮有8个引脚。其中,C1 到 C4 (这神设计,居然从下往上数的)表示四个列;R1 到 R4 (真逗,又变成从上往下数,这种设计,估计是跟PCB板上的线路有关)表示四个行。因此,用八根线来分别控制四行四列。看看电路图。

 

 

看不懂没关系,老周帮你画个变异版本,看起来会简单些。

 

 画得不太正,请见谅。透过这个变异图,能明确看到:行与列交叉的地方都接了个开关(按钮),好,记住这点,下面我们讨论其原理时就好理解了。

 

矩阵按键模块不需要明确地连接电源正负极,而是把所有引脚都与单片机(此处是树莓派)的 GPIO 口相接。要识别哪个键被按下,就要进行“扫描”,思路就是:

1、四个行所连接的 GPIO 口设置为输出,四个列所连接的 GPIO 口设置为输入。

2、四个行设置为输入,四个列为输出。

以上两种思路的原理都一样,任君挑选。不管是四行还是四列,只要有一个是输出,另一个是输入,那么当按钮被按下时,电路接通,它们就会产生通信,然后再逐行/列进行判断,就能分析出是哪个键被按下了。

举个例子,假如我按下了第二行第三列的键。

 

 那么,R1 和 C3 两根线就会接通,如果 R2 输出了低电平,那么 C3 就会输入低电平。于是就能定位到这个被按下的键的坐标—— R2-C3。

于是,如果我们设定行输出,列输入,那么,可以通过执行这个循环来扫描。

for col=0; col<4; col++
    列col :: 输入模式,并由上拉电阻置为高电平

for row=0; row<4; row++
    接线row :: 输出模式
    接线row --> 发送低电平
    for col=0; col<4; col++
        if 列col 读到低电平
            被按下的键:行=row,列=col

首先,把四列设置输入模式,并内部上拉。即通过树莓派内部与电源并联的上拉电阻,使四个列的默认输入值为高电平。

然后,逐行测试,每个行依次输出低电平,再看看是哪个列收到了低电平,就说明电路接通,行列交叉点上的按钮被按下。

 

如果设定为列输出,行输入。

for row=0; row<4; row++
     行row :: 输入,内部上拉

for col=0; col<4; col++
     接线col :: 输出模式
     接线col --> 发送低电平
     for row=0; row<4; row++
          if 行row 读到低电平
               被按下的键:行=row,列=col

原理和上面一样。

 

总的来说就是,输出端发送低电平,如果线路接通,接收端就会收到低电平,其他未接通的会保持默认的高电平

 下面进入敲代码环节。

先写一个 Key 类,包含按键所在的行号与列号,关联的键码(自定义的标签,可以为任意内容字符串),以及一个布尔值属性表示按键是否被按下。

public class Key
{
    public Key(int row, int column, string keycode)
    {
        Row = row;
        Column = column;
        Code = keycode;
        Pressed = false;
    }

    // 行号(从0开始,程序员习惯)
    public int Row { get; set; }
    // 列号(从0开始)
    public int Column { get; set; }
    // 自定义键码(与按键关联的字符,可以自定义)
    public string Code { get; set; }
    // 标志按键是否被按下
    public bool Pressed { get; set; }
}

然后,正式写核心类。为了连贯性,我献上完整的代码,以供鉴宝。

public class KeyScanner : IDisposable
{
    #region 私有成员
    private int[] _rowpins, _colpins;
    private GpioController _gpioctrl;
    private IEnumerable<Key> _keymaps;
    #endregion

    #region 构造函数
    public KeyScanner(int[] rowPins, int[] colPins, IEnumerable<Key> keys)
    {
        if (rowPins is (null or { Length: 0 }))
        {
            throw new ArgumentException(nameof(rowPins));
        }
        if (colPins is (null or { Length: 0 }))
        {
            throw new ArgumentException(nameof(colPins));
        }
        if (keys.Count() != rowPins.Length * colPins.Length)
        {
            throw new ArgumentException(nameof(keys));
        }
        _rowpins = rowPins;
        _colpins = colPins;
        _keymaps = keys;
        _gpioctrl = new();
        // 打开所有接口
        foreach (int p in _rowpins)
        {
            _gpioctrl.OpenPin(p);
        }
        foreach (int p in _colpins)
        {
            _gpioctrl.OpenPin(p);
        }
    }

    public void Dispose()
    {
        // 关闭所有接口
        foreach (int p in _rowpins)
        {
            if (_gpioctrl.IsPinOpen(p))
            {
                _gpioctrl.ClosePin(p);
            }
        }
        foreach (int p in _colpins)
        {
            if (_gpioctrl.IsPinOpen(p))
            {
                _gpioctrl.ClosePin(p);
            }
        }
        _gpioctrl.Dispose();
        _gpioctrl = null;
    }
    #endregion

    #region 公共属性
    // 获取行数
    public int Rows => _rowpins.Length;
    // 获取列数
    public int Columns => _colpins.Length;
    #endregion

    #region 公共方法
    public void Scan()
    {
        // 将所有按键信息全改为未按下状态
        foreach (Key k in _keymaps)
        {
            k.Pressed = false;
        }
        // 行输出,列输入
        // 所有列设置为输入模式,并由内部上拉电阻拉高电平
        foreach (int pin in _colpins)
        {
            _gpioctrl.SetPinMode(pin, PinMode.InputPullUp);
        }
        // 所有的行设置为输出模式
        // 逐行输出低电平,然后看看哪个列接收到低电平
        // 那么就能锁定是哪个按键被按下
        int row, col;
        for (row = 0; row < Rows; row++)
        {
            _gpioctrl.SetPinMode(_rowpins[row], PinMode.Output);
            // 输出低电平
            _gpioctrl.Write(_rowpins[row], 0);
            // 检查每个列,看看谁收到了低电平
            for (col = 0; col < Columns; col++)
            {
                if (_gpioctrl.Read(_colpins[col]) == 0)
                {
                    // 此时被按下按钮的
                    // 行号:row
                    // 列号:col
                    Key theKey = _keymaps.FirstOrDefault(z => z.Column == col && z.Row == row);
                    // 标记为按下状态
                    theKey.Pressed = true;
                }
            }
            // 扫描完后把这一行改为输入模式
            // 不要让它继续输出
            _gpioctrl.SetPinMode(_rowpins[row], PinMode.Input);
        }
    }

    public Key GetKey()
    {
        // 只返回一个
        return _keymaps.FirstOrDefault(z => z.Pressed);
    }

    public ReadOnlySpan<Key> GetKeys()
    {
        // 返回多个
        return _keymaps.Where(z => z.Pressed).ToArray();
    }
    #endregion
}

最最关键的部分是键扫描的代码,单独重播一下。

    public void Scan()
    {
        // 将所有按键信息全改为未按下状态
        foreach (Key k in _keymaps)
        {
            k.Pressed = false;
        }
        // 行输出,列输入
        // 所有列设置为输入模式,并由内部上拉电阻拉高电平
        foreach (int pin in _colpins)
        {
            _gpioctrl.SetPinMode(pin, PinMode.InputPullUp);
        }
        // 所有的行设置为输出模式
        // 逐行输出低电平,然后看看哪个列接收到低电平
        // 那么就能锁定是哪个按键被按下
        int row, col;
        for (row = 0; row < Rows; row++)
        {
            _gpioctrl.SetPinMode(_rowpins[row], PinMode.Output);
            // 输出低电平
            _gpioctrl.Write(_rowpins[row], 0);
            // 检查每个列,看看谁收到了低电平
            for (col = 0; col < Columns; col++)
            {
                if (_gpioctrl.Read(_colpins[col]) == 0)
                {
                    // 此时被按下按钮的
                    // 行号:row
                    // 列号:col
                    Key theKey = _keymaps.FirstOrDefault(z => z.Column == col && z.Row == row);
                    // 标记为按下状态
                    theKey.Pressed = true;
                }
            }
            // 扫描完后把这一行改为输入模式
            // 不要让它继续输出
            _gpioctrl.SetPinMode(_rowpins[row], PinMode.Input);
        }
    }

此处老周采用的是行输出,列输入的方案。流程如下:

1、枚举所有 Key 实例,将 Pressed 属性设置为 false(相当于重置);

2、将所有与列连接的 GPIO 接口设定为输入模式并上拉(默认高电平);

3、枚举每个与行连线的 GPIO 接口,依次输出低电平;

4、在某个行输出低电平后,枚举所有列,看看谁收到了低电平,就说明那个按键被按下,接通了电路;

5、每一行扫描结束后,将其设为输入模式(此步是可选的,主要是为了不让接口继续输出,其实省略这步也没问题,但要保证不要让引脚接触到其他导体,可能会意外放出电流)。

可能有的朋友看过其他单片机中有关轻触开关的教程,会疑惑:老周,你为什么不延时几十毫秒来防止抖动呢?平时用按键开关开灯的时候,如果你注意看的话,会发现在开启的瞬间灯会闪烁。这个就是开关在接通的时候会有短时间的抖动(可能是开关抖,也可能是你手抖),这样会导致有一段时间内电路不稳定。不过,老周这里把 Scan 过程独立出来了——也就是说在扫描按键的过程中不去响应任何操作(不去控制开灯或关灯),而是在扫描之后,通过 GetKey 方法来获取被按下的键,可以有效避免抖动。当然了,你可以每次调用 Scan 方法之间做些延时,防止连续触发(如果按着开关不放就会连续触发,这个得看你怎么去处理了)。

最后,主程序入口点测试代码。

            int[] rowpins = { 23, 24, 25, 16 };
            int[] colpins = { 17, 27, 22, 26 };
            Key[] maps = {
                new(0,0,"S1"),
                new(0,1,"S2"),
                new(0,2,"S3"),
                new(0,3,"S4"),
                new(1,0,"S5"),
                new(1,1,"S6"),
                new(1,2,"S7"),
                new(1,3,"S8"),
                new(2,0,"S9"),
                new(2,1,"S10"),
                new(2,2,"S11"),
                new(2,3,"S12"),
                new(3,0,"S13"),
                new(3,1,"S14"),
                new(3,2,"S15"),
                new(3,3,"S16")
            };
            using KeyScanner scanner = new(rowpins, colpins, maps);
            while (running)
            {
                scanner.Scan();
                Key pk = scanner.GetKey();
                // 当没有按下的键时,会得到 null,跳过处理
                if (pk == null)
                    continue;
                string msg = $"按下了【{pk.Code}】键,第{pk.Row + 1}行第{pk.Column + 1}列";
                Console.WriteLine(msg);
                Thread.Sleep(500);
            }

这两行代码指定了树莓派上使用的引脚号(注意不是板子上的顺序号,而是 GPIO 的BCM编号)。

a、连接 R1-R4,使用了 23、24、25、16 号脚;

b、连接 C1-C4,使用了 17、27、22、26 号脚。

 

发布程序:

dotnet publish -r linux-arm -c Release --no-self-contained

如果你的树莓派上没有 .NET 运行时,可以去掉 --no-self-contained,这样能直接运行,缺点是体积大一些,文件多一些。

把生成的文件全部上传到树莓派,运行。随后可以按不同的键进行测试。

 

现在回过头来看看,前文中提到的上拉电阻,树莓派内部有上拉电阻,因此我们不需要自己接电阻。上拉电阻就是在 GPIO 接口与电源间并联的一个电阻。该电阻阻值很大,几乎没有电流通过。这个并联出来的支路不是用来供电的,所以没有电流通过也不要紧。

老周简单画了个图,不太规范,只求简单好理解。

 

 电阻 R 与 IO 口并联,且接到电源上(假设是 3.3V 电压),现在开关 S 闭合,与开关连接的另一个接口发出了低电平信号。这时候电路接通,电流当然选择畅通无阻的 GPIO 接口,所以 CPU 收到低电平信号。

那要是开关 S 断开呢。

 

 开关 S 断开后,GPIO 口与外部的连接就会断开,此时虽然电阻 R 所在的支路阻力很大(妖魔当道,可能还有土匪拦路打劫,说不定还有色狼),但是,由于通信口断了,电流别无选择,哪怕半路翻车、身首异处,也得闯一闯。就算电阻 R 处无电流能通过,但 R 两端的电势差是存在的,所以此时 CPU 从 R 的下端读到 3.3V,信号保持在高电平状态。

有上拉电阻,当然就会有下拉电阻,其原理一样,只是并联的电阻与 GND 相连,读到电压 0V,保持在低电平状态。

 

 当开关 S 断开后,通信口断开,电阻 R 与 GND 之间的电势差为 0V。于是,CPU 读到的信号保持在低电平。

好,总结一下:上拉电阻使信号默认为高电平,下拉电阻使信号默认为低电平。前提:通信电路断开

为什么要这样做呢?还是回到那个老掉牙话题,计算机只认识 0 和 1,也就是说,你必须给 CPU 下达一个明确的指令,要么是0,要么是1。如果通信电路断开后,那 CPU 咋办,它不知道通信接口那里是啥情况。如果通信接口附近有电场,或者空气中刚好有电荷通过,以及各种不可预知的情况,可能会导致电势产生不规则波动,一会儿高电平,一会儿低电平,信号不确定的时候很容易使 CPU 抽风。因为它不知道你要叫它干吗。

本文示例的源代码,点这里下载