【.NET 與樹莓派】矩陣按鍵

東邪獨孤發表於2021-01-25

歡迎收看火星衛視,本期節目我們們嚴重探討一下矩陣按鍵。

所謂矩陣按鍵,就是一個小鍵盤(其實一塊PCB板),上面有幾個 Key(開關),你不按下去的時候,電路是斷開的,你按下去電路就會接通。至於說有多少個按鈕,這個就看人家工廠怎麼弄了,多見的有 3×3=9個鍵的,有 4×4=16個鍵的。各個按鍵排列成陣勢,所以稱矩陣按鍵(或矩陣開關)。叫法很多,知道是啥玩意兒就行,不必糾結。

先上一張圖,以供列位看官鑑賞。

【.NET 與樹莓派】矩陣按鍵

老周家裡窮,吃飯成問題,故而買了一塊裸板裸鍵的,連鍵帽都沒有的。有套套的當然好看,但太裝13了,這種裸鍵的多好,拿在手上特別有科技感。

這個矩陣有4行4列,上面標印 16 個按鍵(從“S1”到“S16”)。

這個模組剛拿到手的時候,你可能會疑惑——尼馬,怎麼有8個引腳,但沒有電源正極(VCC)和負極(GND),怎麼接線?這種模組比較特殊,它不用接供電相關的線,從上圖我們們看到,這廝有8個引腳。其中,C1 到 C4 (這神設計,居然從下往上數的)表示四個列;R1 到 R4 (真逗,又變成從上往下數,這種設計,估計是跟PCB板上的線路有關)表示四個行。因此,用八根線來分別控制四行四列。看看電路圖。

【.NET 與樹莓派】矩陣按鍵

 

 

看不懂沒關係,老周幫你畫個變異版本,看起來會簡單些。

【.NET 與樹莓派】矩陣按鍵

 

 畫得不太正,請見諒。透過這個變異圖,能明確看到:行與列交叉的地方都接了個開關(按鈕),好,記住這點,下面我們討論其原理時就好理解了。

 

矩陣按鍵模組不需要明確地連線電源正負極,而是把所有引腳都與微控制器(此處是樹莓派)的 GPIO 口相接。要識別哪個鍵被按下,就要進行“掃描”,思路就是:

1、四個行所連線的 GPIO 口設定為輸出,四個列所連線的 GPIO 口設定為輸入。

2、四個行設定為輸入,四個列為輸出。

以上兩種思路的原理都一樣,任君挑選。不管是四行還是四列,只要有一個是輸出,另一個是輸入,那麼當按鈕被按下時,電路接通,它們就會產生通訊,然後再逐行/列進行判斷,就能分析出是哪個鍵被按下了。

舉個例子,假如我按下了第二行第三列的鍵。

【.NET 與樹莓派】矩陣按鍵

 

 那麼,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 號腳。

【.NET 與樹莓派】矩陣按鍵

 

釋出程式:

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

如果你的樹莓派上沒有 .NET 執行時,可以去掉 --no-self-contained,這樣能直接執行,缺點是體積大一些,檔案多一些。

把生成的檔案全部上傳到樹莓派,執行。隨後可以按不同的鍵進行測試。

 

現在回過頭來看看,前文中提到的上拉電阻,樹莓派內部有上拉電阻,因此我們不需要自己接電阻。上拉電阻就是在 GPIO 介面與電源間並聯的一個電阻。該電阻阻值很大,幾乎沒有電流通過。這個並聯出來的支路不是用來供電的,所以沒有電流通過也不要緊。

老周簡單畫了個圖,不太規範,只求簡單好理解。

【.NET 與樹莓派】矩陣按鍵

 

 電阻 R 與 IO 口並聯,且接到電源上(假設是 3.3V 電壓),現在開關 S 閉合,與開關連線的另一個介面發出了低電平訊號。這時候電路接通,電流當然選擇暢通無阻的 GPIO 介面,所以 CPU 收到低電平訊號。

那要是開關 S 斷開呢。

【.NET 與樹莓派】矩陣按鍵

 

 開關 S 斷開後,GPIO 口與外部的連線就會斷開,此時雖然電阻 R 所在的支路阻力很大(妖魔當道,可能還有土匪攔路打劫,說不定還有色狼),但是,由於通訊口斷了,電流別無選擇,哪怕半路翻車、身首異處,也得闖一闖。就算電阻 R 處無電流能通過,但 R 兩端的電勢差是存在的,所以此時 CPU 從 R 的下端讀到 3.3V,訊號保持在高電平狀態。

有上拉電阻,當然就會有下拉電阻,其原理一樣,只是並聯的電阻與 GND 相連,讀到電壓 0V,保持在低電平狀態。

【.NET 與樹莓派】矩陣按鍵

 

 當開關 S 斷開後,通訊口斷開,電阻 R 與 GND 之間的電勢差為 0V。於是,CPU 讀到的訊號保持在低電平。

好,總結一下:上拉電阻使訊號預設為高電平,下拉電阻使訊號預設為低電平。前提:通訊電路斷開

為什麼要這樣做呢?還是回到那個老掉牙話題,計算機只認識 0 和 1,也就是說,你必須給 CPU 下達一個明確的指令,要麼是0,要麼是1。如果通訊電路斷開後,那 CPU 咋辦,它不知道通訊介面那裡是啥情況。如果通訊介面附近有電場,或者空氣中剛好有電荷通過,以及各種不可預知的情況,可能會導致電勢產生不規則波動,一會兒高電平,一會兒低電平,訊號不確定的時候很容易使 CPU 抽風。因為它不知道你要叫它幹嗎。

本文示例的原始碼,點這裡下載

 

相關文章