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

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

就能看到CPU的温度了。

 

posted @ 2021-06-26 17:05  东邪独孤  阅读(6517)  评论(4编辑  收藏  举报