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的温度了。