zoukankan      html  css  js  c++  java
  • 【.NET 与树莓派】LED 数码管驱动模块——TM1638

    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 binDebug
    et5.0publish* pi@<树莓派地址>:/home/pi/<你自己挑个目录>

    然后运行示例程序:dotnet xxx.dll

    就能看到CPU的温度了。

  • 相关阅读:
    python调用WebService遇到的问题'Document' object has no attribute 'set'
    jquery AJAX 拦截器 success error
    js 钩子(hook)
    js 继承
    js Object的复制
    js关于 indexOf
    js重排序,笔记
    js类型检测,笔记
    jquery源码的阅读理解
    Windows IPC 连接详解(转)
  • 原文地址:https://www.cnblogs.com/tcjiaan/p/14929910.html
Copyright © 2011-2022 走看看