3

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

 3 years ago
source link: https://www.cnblogs.com/tcjiaan/p/14322040.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

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

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

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

367389-20210124190702882-961906252.jpg

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

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

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

367389-20210125110457703-1197507793.png

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

367389-20210124192827059-553744791.png

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

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

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

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

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

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

367389-20210124193805126-552771971.png

 那么,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 号脚。

367389-20210125092330739-1012585761.png

发布程序:

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

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

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

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

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

367389-20210125103021000-1320985129.png

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

那要是开关 S 断开呢。

367389-20210125103718620-2134003838.png

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

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

367389-20210125104536018-453291828.png

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

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

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK