欢迎收看火星卫视,本期节目咱们严重探讨一下矩阵按键。
所谓矩阵按键,就是一个小键盘(其实一块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 抽风。因为它不知道你要叫它干吗。
本文示例的源代码,点这里下载