【.NET 與樹莓派】LED 數碼管驅動模組——TM1638

東邪獨孤發表於2021-06-26

LED 數碼管,你可以將它看做是 N 個發光二級管的組合,一個燈負責顯示一個段,七個段組合一位數字,再加一個小數點,這麼一來,一位數碼管就有八段。一般,按照順時針的方向給每個段編號。

上圖中的 h 就是顯示小數點的段,許多電路圖上都標為 dp。

這麼看來,要顯示一位數字,你就需要九根連線線。由於連線的方向不同,又產生了“共陽”和“共陰”兩個概念。

共陽:即共享陽極,也就是電源正極。導線V接到電源正極上(需要串聯電阻,網上很多說要 1k 歐,其實400-500歐就可以了),然後從V並聯出八條走線,分別連線八段數碼管,而每段數碼管的負極都單獨連線。這九根線就成了一正八負。

共陰:就是使用共同的負極。用八條線(設為V1到V8),分別單獨連線電源正極,然後串聯電阻,依次接到八段數碼管上,最後每段數碼管的負相同,即八正一負。

你要是覺得別人的圖太複雜看不懂,老周替你找了一張簡單的。

至於說怎麼分辨出共陽和共陰,根據上面對二者的特點描述,方法也不難。首先,一條線連到電源正極,一條聯到負極(當然不要忘了串電阻),然後在數碼管上隨便找兩個引腳接入電路,並且要保證連線後其中某一段LED會亮的。這時候,你保持電源負極不變,用其他引腳輪流去接觸電源正極,如果有多個LED發光,說明你手上的玩意兒是共陰的。同樣,保持電源正極連線不變,依次嘗試把其他引腳接到負極,如果有多段LED發光,說明是共陽的。

那麼,開發板如何控制哪段LED發光,哪段不發光?這裡頭的原理,還是那個不變的規律——電流從高電勢流向低電勢,即電壓高的會流向電壓低的。

1、共陽數碼管:共用電源正極,可以認為它輸出的是高電平,然後八個段接到 GPIO 口,要想哪段LED發光就讓對應的介面輸出低電平,不發光就輸出高電平。

2、共陰數碼管:共用電源負極,可以認為它輸出的是低電平,要讓某段LED發光,就讓對應的 GPIO 口輸出高電平。

一位數碼管就佔用了九個 GPIO 介面了,要是兩位數呢,再加九個,那就成了十八個了,要是有四位數呢,那估計你要買幾塊開發板了。就算你拼接了幾塊開發板,如何統一控制就很頭痛了。為了節約 GPIO 引腳資源,於是又有新名詞問世了——段掃描。

這裡我們們就別管它是靜動掃描還是動態掃描,因為我們今天的主題是藉助專門的驅動晶片的,所以有關掃描的事兒,簡單瞭解就行。為了減少接線數量,可以把每位數的段合為一個並聯電路,再單獨一根線來控制數字位。例如

 

 這麼一折騰,四位數碼管只需要 4 + 8 = 12 根線就能連線。不過,細心的你,此時肯定發現問題了,要是這樣連線,豈不是在同一時刻只能允許一段LED發光?那我需要多段LED發光咋辦?那就得掃描了,實際上就是不斷地執行迴圈,輪番地切換控制,只要切換的速度夠快,人眼是覺察不到閃爍的,於是就可以瞞天過海,騙過你的眼睛了。至於說能不能騙過貓的眼睛就不知道了,這有待生物學家們去驗證了。

比如,我要讓這四位數碼管顯示1213,好的,“1”是 b、c 段發光,其他段不發光

 

 “2”是 a、b、d、e、g 五段發光。

 

 “3”是a、b、c、d、g 發光。

 

 

第一步,顯示第一位“1”,把 1+ 接通,2+ 到 4+ 不通,再把 b c 段接通;

第二步,顯示第二位“2”,把 2+ 接通,1+、3+、4+ 不通,再接通 a b d e g 。

第三步,顯示第三位“1”,和第一位的段相同,但數位上是接通 3+,1+、2+、4+不通。

第四步,顯示第四位“3”,把 4+ 接通,其他位不通,再接通a b c d g。

最後讓上面四個步驟不斷地迴圈

只要你的微控制器夠快,你幾乎看不到閃爍。但樹莓派是帶作業系統的,不管怎樣,通過系統層再到硬體的呼叫肯定會慢一拍,會出現閃爍或者部分LED段亮度不夠的情況。這個迴圈可能用純粹的微控制器開發板會快一點。

然而,哪怕用上了掃描方案,還是不能解決問題。第一,佔用開板的介面仍然很多,要是有八位數碼管,那得16個以上的介面了;第二,開發板把“精力”都花在迴圈掃描上了,就沒空去處理其他事情了,這樣未免太浪費。於是,就出現了專門驅動LED數碼管的晶片。常見的如  74HC595、TM1637、TM1638、TM1650 等。

本文老周介紹的是 TM1638,這個“TM”不是“他媽”的意思,而是指“天微電子”。所以,你不能讀作“他媽 1638”。1637 在微軟開源的 Iot.Bindings 庫裡面已經封裝了。現在某寶上能買到的 TM1637 模組基本上是封裝為時鐘模組,即沒有小數點,而是中間加個“:”,顯示時鐘用的。

而 TM1638 一般封裝為一個複合模組,老周買的是這個,有八位數碼管,下面有八個按鈕(有的是十六個按鈕),頂部有八個發光二極體。

這個模組有除了供電的兩個引腳,用三根線來控制,怎麼說也比用十幾根線來得簡便。

STB:可以理解為命令控制線,在傳送命令之前,STB要拉到低電平,發完命令或讀取完按鈕資訊後,需要把STB拉回高電平。

CLK:時鐘線,其實用來控制硬體的資料處理節奏。

DIO:資料線,高電平表示1,低電平表示0。

注意:不管是傳送還是接收資料,都是從位元組的低位開始的。

 

這個模組,其實如果玩熟練了,並不複雜,只是它用的不是標準的 SPI、IIC 協議,所以我們只能自行封裝。依據資料手冊,每個二進位制位的讀寫操作都在時鐘線的上升沿完成。上升沿就是 CLK 線從低電平轉到高電平的瞬間,這個時間極短,就算偵聽 PinEventTypes.Rising 事件(類似微控制器中的中斷),有可能也來不及,因為模組一旦收到此訊號就會馬上處理。所以,我們在寫程式碼時,可以換個思路——在每個時鐘上升沿到來之前把資料線DIO 的電平固定好,這樣就不怕由於時間來不及而導致讀寫錯位了。

不妨看看資料手冊中的時序圖。

 從時序圖中可以看到。在CLK線發生上升沿時,DIO必須準備好資料(不管是拉高還是拉低),因為 TM1638 模組是以上升沿作為資料傳送的訊號的。也就是說,只要是在CLK的上升沿到來之前,都可以修改DIO的電平。

故,下面的 WriteByte 方法,兩個版本都是可以的。

        // 版本一
        void WriteByte(byte val)
        {
            // 從低位傳起
            int i;
            for (i = 0; i < 8; i++)
            {
                // 拉低clk線
                _gpio.Write(CLKPin, 0);
                // 修改dio線
                if ((val & 0x01) == 0x01)
                {
                    _gpio.Write(DIOPin, 1);
                }
                else
                {
                    _gpio.Write(DIOPin, 0);
                }
                // 右移一位
                val >>= 1;
                // 拉高clk線,向模組發出一位
                _gpio.Write(CLKPin, 1);
            }
        }

         // 版本二
        void WriteByte(byte val)
        {
            // 從低位傳起
            int i;
            for (i = 0; i < 8; i++)
            {
                // 修改dio線
                if ((val & 0x01) == 0x01)
                {
                    _gpio.Write(DIOPin, 1);
                }
                else
                {
                    _gpio.Write(DIOPin, 0);
                }
                // 右移一位
                val >>= 1;
                // 拉低clk線
                _gpio.Write(CLKPin, 0);
                // 拉高clk線,向模組發出一位
                _gpio.Write(CLKPin, 1);
            }
        }

兩個版本的區別在於:第一個版本中,每次傳送二進位制位時,先拉低CLK,再改變DIO,再拉高CLK;第二個版本則是先改變DIO的電平,再拉低CLK,然後又拉高CLK。

其核心就是——每個二進位制位都要製造一個CLK的上升沿,所以CLK在什麼時候拉低不重要,重要的是只有拉低再拉高才能產生電平上升的跳變過程

而STB線的使用並不是看每個位元組,而是看命令,傳送命令前,STB要拉低電平,傳送完命令後,STB線要拉高。命令可能是一個位元組,也可能是兩個、三個位元組。總之,傳送一條命令前要拉低STB,發完後要拉高STB

 

下面看看有哪些命令可用。

 

 這個表把命令分為三類:設定命令、顯示控制、要操作的暫存器的地址。模組通過一個位元組的最高兩位(B6、B7就是第7、8位)來區分。比如,你要調整數碼管的顯示亮度,屬於顯示控制命令,因此,你寫入的命令位元組的最高兩位必須是 0b 10xx xxxx。

1、設定命令

格式:0b_01xx_xxxx

 

 通過上表,會發現一件事——當把無關項全填上0後,原來有兩條命令是一樣的。配置模組為寫顯示暫存器模式時的命令是 0100 0000,並且將暫存器定址方式設為自動增加模式時,命令也是 0100 0000。

後面兩條測試命令我們可以不管它,先看第一條,把資料寫到顯示暫存器,也就是說你要八位數碼管顯示會麼,就把要顯示的LED段資料寫入對應的暫存器中。不知道大夥伴們還記不得前文中說的,數碼管每個位有七段,加上小數點是八段,每段對應一個二進位制位,喲西,正好是一個位元組。排列順序是從低位到高位。

dp   g   f   e   d   c   b   a

0     0  0   0   0   0   0   0

如果要顯示0,即a b c d e f 要點亮,那就是 0011 1111;

要顯示1,即 b c 段要點亮,也就是 0000 0110;

要顯示3,即 a b c d g 段要點亮,就是 0100 1111。

最高位是小數點,若要讓3後面的小數點點亮,就是 1100 1111。

要點亮的位放 1,不點亮的位放 0。

 

這款TM1638模組有八位數碼管,因此,需要有八個暫存器來存放,每個暫存器對應一位。

 

 可資料手冊中我們看到了十六個暫存器,地址從 0x00 到 0x0F。原來每個數碼位有兩個位元組,佔了兩個暫存器。第一個位元組 SEG1 到 SEG8,就是一位數碼管中的八段,那麼第二個位元組中還有兩位(SEG9、SEG10)是啥?回過頭再看看這模組,每一位數碼管上面都對應有一盞小燈,所以這第二個位元組的第一位(SEG9)就是用來控制這個小燈亮不亮的,因為模組只為單個數碼管配了一個燈,所以只有 SEG9 位有效,SEG10 用不上。

舉個例子,假如我要在第二位數碼管上顯示“1”,從表中看到,GRID2 的 SEG1-SEG8,對應暫存器地址為 0x02,前面我們分析過,顯示“1”,就是讓 b c 段發光,位元組是 0000 0110,所以,往 0x02 寫入 0x06(0110)即可,如果還想點亮第二位數碼管上面的燈,就向 0x03 寫入 0x01(0000 0001)即可。

我們們進一步總結發現,點亮數碼管的暫存器地址都是偶數,即 2 * n,假設要控制第一位,地址就是 2 * 0 = 0,要控制第三位,則地址就是  2 * 2 = 4。排序從0開始,即第0位到第7位。

點亮數碼管上面的小燈,其暫存器地址是奇數,即 2 * n + 1,例如,要點亮第五位的小燈,暫存器地址為 2 * 4 + 1 = 9,寫入 0x80。

 

2、定址與寫資料

下面說說兩種暫存器定址方式,即設定命令中的

 

 如果是自動增加地址,要傳送兩條命令:

1、(STB拉低)一個位元組,0100 0000,表示自增地址(STB拉高);

2、(STB拉低)N 個位元組,其中第一個位元組是首地址,之後是資料。模組會將第一個資料位元組寫入首地址,然後地址自動 +1,再寫第二個,……

     例如,0x02 0x81 0x77 0x25,標定首地址是 0x02,把 0x81 寫入 0x02;然後地址 +1 變成 0x03,再把 0x77 寫入0x03;地址再++,變成0x04,把0x25寫入0x04(STB拉高)。

如果是固定地址呢

1、(STB拉低)傳送命令 0100 0100,即 0x44(STB拉高);

2、(STB拉低)寫入兩個位元組,第一個是地址 0x02,第二個是資料0x80(STB拉高);

3、(STB拉低)寫入兩個位元組,第一個是地址 0x03,第二個是資料 0x77(STB拉高);

4、(STB拉低)寫入兩個位元組,第一個是地址 0x04,第二個是資料 0x25(STB拉高)。

時序如下

 

 

 

 

3、顯示控制命令

 

 顯示控制命令都是 10xx xxxx 格式,高四位位元組都是 1000,引數設定用到的只有低四位。其中,低三位用來設定亮度,表中的“消光數量”說白了就是亮度調整,範圍是 0 - 7,因為只有三個二進位制位,所以最大值只能是 7。第四位用來設定是否開啟數碼管的顯示,如果為 0 表示關閉數碼管顯示,就算你把亮度調到7也不會顯示;如果為 1 表示開啟數碼管顯示。說簡單一點就是,第四位,1 時開顯示器,0 是關顯示器

 

=====================================================================================

好了,前面所講的都是理論介紹,這個模組還有一個掃描按鍵的功能,這個老周下一篇爛文再扯,本文的重點是說說怎麼寫視訊記憶體(顯示暫存器),即讓數碼管顯示指定內容。

前文中已經寫好了 WriteByte 方法,下面我們們再加一層封裝,寫個 WriteCommand 方法,用於向 TM1638 傳送命令。

        void WriteCommand(byte cmd, params byte[] data)
        {
            // 拉低stb
            _gpio.Write(STBPin, 0);
            WriteByte(cmd);
            if (data.Length > 0)
            {
                // 寫附加資料
                foreach (byte b in data)
                {
                    WriteByte(b);
                }
            }
            // 拉高stb
            _gpio.Write(STBPin, 1);
        }

如果命令只有一個位元組,那麼傳引數時只考慮 cmd 引數,data 引數忽略;如果命令帶附加資料,則傳給 data 引數。比如上面說的自動增加地址,cmd 傳暫存器地址,data 傳要寫入各個暫存器的資料。

隨後,我們再往上封裝一層,實現 SetChar 方法,直接設定要顯示的資料,以及顯示在第幾位數碼管上。

        public void SetChar(byte c, byte pos)
        {
            // 暫存器地址
            byte reg = (byte)(pos * 2);
            byte com = (byte)((byte)TM1638Command.SetDisplayAddress | reg);
            WriteCommand(com, c);
        }

引數 c 表示要寫入的資料,也就是一位數碼管中各個段的二進位制位的值;pos 引數指的顯示在第幾位,老周買的這個模組有八位數碼管,所以,pos 引數的取值範圍是 0 到 7。暫存器的地址就是 pos * 2。

為了在初始化時,或者需要時清空所有數碼管的顯示(所有二進位制位置0),還要寫一個 CleanChars 方法。

        public void CleanChars()
        {
            int i = 0;
            while(i < 8)
            {
                SetChar(0x00, (byte)i);
                i++;
            }
        }

接下來是控制每位數碼管對應的小燈。

        public void SetLED(byte n, bool on)
        {
            byte addr = (byte)(n * 2 + 1); //暫存器地址
            // 1100_xxxx
            byte cmd = (byte)((byte)TM1638Command.SetDisplayAddress| addr );
            byte data = (byte)(on? 1 : 0);
            WriteCommand(cmd,data);
        }

        public void CleanLEDs()
        {
            int i=0;
            while(i<8)
            {
                SetLED((byte)i, false);
                i++;
            }
        }

n 選擇控制第幾個燈,和數碼管一樣,從 0 到 7,on 表示是否點亮,true 點亮否則熄滅。

上面程式碼用的命令,可以用列舉型別宣告,使用時直接訪問。

    internal enum TM1638Command : byte
    {
        // 讀按鈕掃描
        ReadKeyScanData = 0b_0100_0010,
        // 自動增加地址
        AutoIncreaseAddress = 0b_0100_0000,
        // 固定地址
        FixAddress = 0b_0100_0100,
        // 選擇要讀寫的暫存器地址
        SetDisplayAddress = 0b_1100_0000,
        // 顯示控制設定
        DisplayControl = 0b_1000_0000
    }

 

 

為了方便操作,也可以將常用的數字(0-9)的資料用常量宣告,使用時直接引用。

    public class Numbers
    {
        public const byte Num0 = 0b_0011_1111;  //0
        public const byte Num1 = 0b_0000_0110;  //1
        public const byte Num2 = 0b_0101_1011;  //2
        public const byte Num3 = 0b_0100_1111;  //3
        public const byte Num4 = 0b_0110_0110;  //4
        public const byte Num5 = 0b_0110_1101;  //5
        public const byte Num6 = 0b_0111_1101;  //6
        public const byte Num7 = 0b_0000_0111;  //7
        public const byte Num8 = 0b_0111_1111;  //8
        public const byte Num9 = 0b_0110_1111;  //9

        public const byte DP = 0b_1000_0000;    //小數點

          public static byte GetData(char c) =>
                c switch
                {
                    '0'     => Num0,
                    '1'     => Num1,
                    '2'     => Num2,
                    '3'     => Num3,
                    '4'     => Num4,
                    '5'     => Num5,
                    '6'     => Num6,
                    '7'     => Num7,
                    '8'     => Num8,
                    '9'     => Num9,
                    _       => Num0
                };
    }

 

下面是 TM1638 類的完整程式碼,這裡老周選用的是固定地址的暫存器讀寫方式。

    public class TM1638 : IDisposable
    {
        GpioController _gpio;

        // 建構函式
        public TM1638(int stbPin, int clkPin, int dioPin)
        {
            STBPin = stbPin;    // STB 線連線的GPIO號
            CLKPin = clkPin;    // CLK 線連線的GPIO號
            DIOPin = dioPin;    // DIO 線連線的GPIO號
            _gpio = new();
            // 將各GPIO引腳初始化為輸出模式
            InitPins();
            // 設定為固定地址模式
            InitDisplay(true);
        }

        // 開啟介面,設定為輸出
        private void InitPins()
        {
            _gpio.OpenPin(STBPin, PinMode.Output);
            _gpio.OpenPin(CLKPin, PinMode.Output);
            _gpio.OpenPin(DIOPin, PinMode.Output);
        }
        private void InitDisplay(bool isFix = true)
        {
            if (isFix)
            {
                WriteCommand((byte)TM1638Command.FixAddress);
            }
            else
            {
                WriteCommand((byte)TM1638Command.AutoIncreaseAddress);
            }
            // 清空顯示
            CleanChars();
            CleanLEDs();
            WriteCommand(0b1000_1111); //亮度最高 + 開啟顯示
        }

        #region 公共屬性
        // 控制引腳號
        public int STBPin { get; set; }
        public int CLKPin { get; set; }
        public int DIOPin { get; set; }
        #endregion

        public void Dispose()
        {
            _gpio?.Dispose();
        }

        #region 輔助方法
        void WriteByte(byte val)
        {
            // 從低位傳起
            int i;
            for (i = 0; i < 8; i++)
            {
                // 拉低clk線
                _gpio.Write(CLKPin, 0);
                // 修改dio線
                if ((val & 0x01) == 0x01)
                {
                    _gpio.Write(DIOPin, 1);
                }
                else
                {
                    _gpio.Write(DIOPin, 0);
                }
                // 右移一位
                val >>= 1;
                //_gpio.Write(CLKPin, 0);
                // 拉高clk線,向模組發出一位
                _gpio.Write(CLKPin, 1);
            }
        }


        void WriteCommand(byte cmd, params byte[] data)
        {
            // 拉低stb
            _gpio.Write(STBPin, 0);
            WriteByte(cmd);
            if (data.Length > 0)
            {
                // 寫附加資料
                foreach (byte b in data)
                {
                    WriteByte(b);
                }
            }
            // 拉高stb
            _gpio.Write(STBPin, 1);
        }
        #endregion

        public void SetChar(byte c, byte pos)
        {
            // 暫存器地址
            byte reg = (byte)(pos * 2);
            byte com = (byte)((byte)TM1638Command.SetDisplayAddress | reg);
            WriteCommand(com, c);
        }
        public void SetLED(byte n, bool on)
        {
            byte addr = (byte)(n * 2 + 1); //暫存器地址
            // 1100_xxxx
            byte cmd = (byte)((byte)TM1638Command.SetDisplayAddress| addr );
            byte data = (byte)(on? 1 : 0);
            WriteCommand(cmd,data);
        }
        public void CleanChars()
        {
            int i = 0;
            while(i < 8)
            {
                SetChar(0x00, (byte)i);
                i++;
            }
        }
        public void CleanLEDs()
        {
            int i=0;
            while(i<8)
            {
                SetLED((byte)i, false);
                i++;
            }
        }
    }

下面簡單試一下,在第一位數碼管上顯示4,第四位數碼管上顯示2,第七位數碼管上顯示5。並點亮第二、第八盞小燈。

        static void Main(string[] args)
        {
            using TM1638 dev = new(13, 19, 26);
            dev.SetChar(Numbers.Num4, 0);
            dev.SetChar(Numbers.Num2, 3);
            dev.SetChar(Numbers.Num5, 6);
            dev.SetLED(1, true);
            dev.SetLED(7, true);
        }

上傳到樹莓派上面,執行效果如下圖所示。

 

再給一個例子,我們們讀取一下樹莓派當前的 CPU 溫度,並用數碼管顯示。

        static void Main(string[] args)
        {
            using TM1638 dev = new(13, 19, 26);
            while (true)
            {
                string result = File.ReadAllText("/sys/class/thermal/thermal_zone0/temp");
                // 還要除以1000
                result = (float.Parse(result) / 1000f).ToString("#.00");
                Console.WriteLine("計算結果:\"{0}\"", result);
                // 拆分字串,顯示各個數字
                int len = result.Length;
                List<byte> datas = new List<byte>();
                for (byte i = 0; i < len; i++)
                {
                    // 小數點不單獨佔一個位,要忽略
                    if (result[i] == '.')
                    {
                        continue;
                    }
                    char ch = result[i];
                    // 獲取顯示資料
                    byte b = Numbers.GetData(ch);
                    // 如果該位不是最後一位
                    // 且下一個字元是小數點,則應該點亮 DP
                    if (i < (len - 1) && result[i + 1] == '.')
                    {
                        b |= Numbers.DP;
                    }
                    datas.Add(b);
                }
                for (byte x = 0; x < datas.Count; x++)
                {
                    dev.SetChar(datas[x], x);
                }
                Thread.Sleep(2000);
            }
        }

執行 dotnet 命令釋出程式碼。

dotnet publish

執行 scp 命令上傳到樹莓派。

scp -r bin\Debug\net5.0\publish\* pi@<樹莓派地址>:/home/pi/<你自己挑個目錄>

然後執行示例程式:dotnet xxx.dll

就能看到CPU的溫度了。

 

相關文章