zoukankan      html  css  js  c++  java
  • 【.NET 与树莓派】矩阵按键

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

    所谓矩阵按键,就是一个小键盘(其实一块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 抽风。因为它不知道你要叫它干吗。

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

    出处:https://www.cnblogs.com/tcjiaan/p/14322040.html

    您的资助是我最大的动力!
    金额随意,欢迎来赏!
    款后有任何问题请给我留言。

    如果,您认为阅读这篇博客让您有些收获,不妨点击一下右下角的推荐按钮。
    如果,您希望更容易地发现我的新博客,不妨点击一下绿色通道的关注我。(●'◡'●)

    如果你觉得本篇文章对你有所帮助,请给予我更多的鼓励,求打             付款后有任何问题请给我留言!!!

    因为,我的写作热情也离不开您的肯定支持,感谢您的阅读,我是【Jack_孟】!

  • 相关阅读:
    Spring小结
    ByteBuffer使用之道
    NIO组件Selector调用实例
    NIO组件Selector详解
    NIO机制总结
    NIO组件Selector工作机制详解(下)
    javascriptBOM_DOM
    【前端】javascript基础学习
    【前端】CSS基础学习
    【mongodb】比较符及修改器
  • 原文地址:https://www.cnblogs.com/mq0036/p/14392203.html
Copyright © 2011-2022 走看看