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; //小數點
}
下面是 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的溫度了。