6

【.NET 与树莓派】LED 数码管驱动模块——TM1638

 2 years ago
source link: https://www.cnblogs.com/tcjiaan/p/14929910.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

【.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

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

367389-20210626115252630-1836617003.png

 这个表把命令分为三类:设置命令、显示控制、要操作的寄存器的地址。模块通过一个字节的最高两位(B6、B7就是第7、8位)来区分。比如,你要调整数码管的显示亮度,属于显示控制命令,因此,你写入的命令字节的最高两位必须是 0b 10xx xxxx。

1、设置命令

格式:0b_01xx_xxxx

367389-20210626120154122-1428579485.png

 通过上表,会发现一件事——当把无关项全填上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模块有八位数码管,因此,需要有八个寄存器来存放,每个寄存器对应一位。

367389-20210626121528836-575132281.png

 可数据手册中我们看到了十六个寄存器,地址从 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、寻址与写数据

下面说说两种寄存器寻址方式,即设置命令中的

367389-20210626123420121-1226249979.png

 如果是自动增加地址,要发送两条命令:

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拉高)。

367389-20210626124651843-1929257488.png

 

367389-20210626124748491-972931854.png

3、显示控制命令

367389-20210626125017321-1585169155.png

 显示控制命令都是 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);
        }

上传到树莓派上面,运行效果如下图所示。

367389-20210626160848083-1588141898.jpg

再给一个例子,咱们读取一下树莓派当前的 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的温度了。

367389-20210626170411598-758524860.jpg

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK