简介:资源包基于STM32F103C8T6驱动8x8 LED点阵模块,搭配MAX7219芯片实现数字0至9的显示,适合嵌入式入门开发者学习SPI通信与LED扫描控制。包内共121个文件,涵盖STM32标准外设库的31个h头文件与30个c源文件、11个d依赖文件、11个o目标文件、10个crf编译中间文件,以及uvprojx工程文件、hex烧录文件、bat批处理工具和说明文档等,整体压缩包约1.2MB,工程结构完整,可直接用Keil打开编译并烧录到C8T6核心板验证。已有420人学习下载。代码提供初始化函数、数字点阵图案生成以及主循环数据传输示例,能够帮助理解MAX7219的串行驱动时序和SPI接口配置,同时包含硬件连接与调试思路,对于从零搭建点阵显示项目具有实用参考价值。
1. 8X8LED点阵配MAX7219:STM32F103C8T6上最省IO的显示方案
拿到这个「基于STM32F103C8T6的8X8LED点阵模块(MAX7219)显示0-9」的工程压缩包时,多数人第一反应是"点阵不就是8块钱一块的屏吗,直接怼GPIO不就行了"。但真把64个LED全接到单片机上,IO不够是小事,刷新不过来才是大问题。MAX7219这颗芯片就是为了解决这件事——用三根线(CLK、DIN、CS)串出64个LED的控制权,而且自带扫描、消影和亮度调节。这个方案特别适合刚把跑马灯写完、想认真学SPI时序的入门者,也适合用F103C8T6做课设计数器的同学:硬件成本低、代码量不大,踩一遍坑之后对移位寄存器、数据手册和字模编码的认知会直接上一个台阶。这篇就把从点阵引脚识别到数字0-9稳定显示的全过程拆开讲,包括我调试时翻过车的几个地方。
2. MAX7219与8X8点阵的驱动原理:从"看不懂时序"到"会算字模"
2.1 MAX7219为什么能一个芯片管64个灯:寄存器与扫描机制
MAX7219内部其实是一个"移位寄存器 + 锁存器 + 扫描控制"的复合体。你要显示一张8x8的图像,不需要告诉它每一行每一列的亮灭,而是把64位数据按"行"拆成8个字节,分别写进它的8个显示寄存器(DIG0~DIG7对应Row0~Row7)。芯片内部用约800Hz的频率轮流驱动每一行,人眼看到的就是一张完整的画面。这和数码管动态扫描是同一个原理,只不过MAX7219把扫描时序、行驱动电流、消影都封装好了。
所以驱动MAX7219的本质不是"逐点亮灯",而是"往寄存器里填行数据"。每次写入一个16位数据包:前8位是寄存器地址,后8位是数据。地址0x01~0x08是行数据寄存器,0x09是译码模式,0x0A是亮度,0x0B是扫描边界,0x0C是关机/开机控制,0x0F是显示测试。我在调试初期最常犯的错就是只写行数据、不写控制寄存器,结果上电后点阵显示的是芯片内部随机值。正确顺序必须是:先初始化控制寄存器,再写行数据,而且初始化代码要在主循环之前跑完。
2.2 字模怎么算:行映到寄存器,列映到位
8x8点阵显示数字"0"时,字模本质上是一个8x8的布尔矩阵。常见取模方式是"行优先、低位在右":每一行用1个字节表示,bit0对应最右边的LED。数字"0"的典型字模就是{0x3E,0x51,0x49,0x45,0x3E}这5个字节再加3个空行。第一行0x3E=0b00111110,表示第1行从左到右第2~7列点亮,形成一个"0"的上半弧。我建议你在纸上把数字"0"的8行8列画成格子,然后自己手动填一遍位,再用取模软件核对自己填的对不对——这个动作比直接抄代码重要得多,因为后面一旦显示倒置或镜像,你能立刻判断是字模方向的问题还是硬件接线的问题。
还有一个容易忽略的点:MAX7219的SEG引脚接的是点阵的列,DIG引脚接的是行,但不同卖家模块的丝印标注不一定遵循同一规则。有的模块把点阵的"行"接到了SEG上。所以拿到板子第一步不是写代码,而是用万用表蜂鸣档测一下模块上标着DIG0的那个引脚,顺着走线确认它到底连到点阵的第几行。我见过同学因为接反,把字模倒过来看了一天,最后发现是模块引脚定义和手册对不上。
2.3 数据手册上必看的三个时序参数
软件模拟SPI驱动MAX7219时,代码看着简单,但时序参数才是坑。手册里写得很清楚:CLK高电平最小持续时间为50ns,CLK低电平最小也是50ns,DIN数据必须在CLK上升沿之前至少25ns稳定。对于STM32F103C8T6在72MHz主频下,一条GPIO翻转指令大概几十ns,如果你在翻转CLK和设置DIN之间不加任何延时,虽然大多数时候也能工作,但温度一高、模块线一长就会间歇性花屏。
我一般的做法是:在DIN设置后加两个空指令(__NOP()),在CLK拉高前再加两个空指令。另外CS信号必须在16位数据全部移入之后再拉高,且CS拉高后至少要等100ns才能进行下一次传输。这个100ns很容易被忽略,尤其是连续刷新多行时,如果你在一个for循环里快速调用发送函数,CS的低电平保持时间可能远小于手册要求,造成数据锁存不稳。真遇到"软件跑得飞快但画面偶尔闪"的玄学问题时,先把这段时序延时加大,八成能解决。
3. 用STM32F103C8T6驱动MAX7219:最小可跑代码与三线SPI时序
3.1 引脚接线与硬件准备
这块方案里我推荐用软件SPI而不是硬件SPI,原因是MAX7219没有MISO,硬件SPI的NSS管理在这种"单工写"场景下反而要多配几个寄存器,得不偿失。接线清单很简单:PB12接CS,PB13接CLK,PB14接DIN,GND接GND,VCC接5V。注意STM32F103C8T6的IO是3.3V电平,MAX7219芯片是5V供电,但逻辑输入高电平阈值大约在1.6V左右,所以3.3V高电平能直接驱动。前提是你的点阵模块上没有做额外隔离,如果模块带了74HC245之类缓冲器,有的缓冲器5V供电时对3.3V输入不够友好,那就需要再确认一下。
硬件准备上还需要一个100mA以上能力的5V电源。MAX7219在64个LED全亮时电流接近300mA,别用开发板USB口的5V硬撑,我试过用USB供电同时在点阵上跑满屏全亮,屏幕亮度明显下降,单片机偶尔复位。更稳妥的是单独给模块供5V,STM32F103C8T6用3.3V供电,两边只共地。这样既保证了逻辑电平匹配,又不会让USB口的压降拖垮点阵亮度。
3.2 软件SPI主循环发送16位数据:核心发送函数
先写最底层的发送函数。这里用标准外设库的GPIO操作,方便移植。
#define CS_LOW() GPIO_ResetBits(GPIOB, GPIO_Pin_12) #define CS_HIGH() GPIO_SetBits(GPIOB, GPIO_Pin_12) #define CLK_LOW() GPIO_ResetBits(GPIOB, GPIO_Pin_13) #define CLK_HIGH() GPIO_SetBits(GPIOB, GPIO_Pin_13) #define DIN_LOW() GPIO_ResetBits(GPIOB, GPIO_Pin_14) #define DIN_HIGH() GPIO_SetBits(GPIOB, GPIO_Pin_14) void MAX7219_SendData(uint8_t addr, uint8_t data) { uint8_t i; CS_LOW(); // CS拉低,开始移位数据 for (i = 0; i < 8; i++) { // 先发送8位寄存器地址 CLK_LOW(); if (addr & 0x80) DIN_HIGH(); else DIN_LOW(); __NOP(); __NOP(); // 给数据建立时间 CLK_HIGH(); addr <<= 1; // 低位先出,高位右移 } for (i = 0; i < 8; i++) { // 再发送8位数据 CLK_LOW(); if (data & 0x80) DIN_HIGH(); else DIN_LOW(); __NOP(); __NOP(); CLK_HIGH(); data <<= 1; } CS_HIGH(); // CS上升沿锁存16位数据 }这段代码的逻辑是:CS低电平时,MAX7219内部的移位寄存器在每个CLK上升沿把DIN上的电平移入一位;先移入的是高字节(addr),然后是低字节(data),最后CS拉高,把16位数据从移位寄存器锁存到目标寄存器里。addr <<= 1配合addr & 0x80实现了先送高位,也就是MSB First。__NOP()是在72MHz主频下大约各占一个指令周期,用来保证DIN信号在CLK上升沿之前稳定下来。如果你改用了其它主频,可以把__NOP()换成for (volatile int i = 0; i < 1; i++);这种空循环。
3.3 初始化寄存器顺序:这七个步骤缺一不可
初始化顺序不能随便换,因为MAX7219内部有些寄存器是上电默认值,不设对的话后面写行数据会被忽略或叠加异常。
void MAX7219_Init(void) { MAX7219_SendData(0x0C, 0x01); // 0x0C是Shutdown寄存器,写1进入正常模式 MAX7219_SendData(0x09, 0x00); // 0x09是译码模式,写0不用BCD译码,点阵必须关译码 MAX7219_SendData(0x0B, 0x07); // 0x0B是扫描边界,写0x07表示扫描8行 MAX7219_SendData(0x0A, 0x08); // 0x0A是亮度控制,0x08是中等亮度 MAX7219_SendData(0x0F, 0x00); // 0x0F是显示测试,写0关闭全亮测试 MAX7219_SendData(0x01, 0x00); // 清空第1行数据,防止上电乱码 MAX7219_SendData(0x02, 0x00); // 清空其余七行,保持一致 MAX7219_SendData(0x03, 0x00); MAX7219_SendData(0x04, 0x00); MAX7219_SendData(0x05, 0x00); MAX7219_SendData(0x06, 0x00); MAX7219_SendData(0x07, 0x00); MAX7219_SendData(0x08, 0x00); }为什么先把0x0C写成1?因为MAX7219上电默认处于Shutdown模式,此时内部时钟停止,写行寄存器不生效。0x09写0是因为点阵模式下如果开了BCD译码,行数据会被芯片内部的译码逻辑篡改成数码管段码,直接导致画面完全错乱。0x0B写0x07表示扫描8行,如果只写0x00就只有第一行能亮。亮度寄存器0x0A的取值范围是0x00~0x0F,我建议调试阶段先用0x08,亮度太强时眼睛容易累,数值低于0x02时在强光下几乎看不清。清空行数据这步很容易被漏掉,但不清空的话,上电瞬间点阵上会随机亮几个点,看起来像故障。
3.4 显示0-9:字模表与主循环怎么配合
有了发送和初始化,显示数字就只剩"查表"这件小事。字模表定义成常量数组放在源文件顶部:
const uint8_t font_0_9[10][8] = { {0x3E,0x51,0x49,0x45,0x3E,0x00,0x00,0x00}, // 0 {0x00,0x42,0x7F,0x40,0x00,0x00,0x00,0x00}, // 1 {0x42,0x61,0x51,0x49,0x46,0x00,0x00,0x00}, // 2 {0x21,0x41,0x45,0x4B,0x31,0x00,0x00,0x00}, // 3 {0x18,0x14,0x12,0x7F,0x10,0x00,0x00,0x00}, // 4 {0x27,0x45,0x45,0x45,0x39,0x00,0x00,0x00}, // 5 {0x3C,0x4A,0x49,0x49,0x30,0x00,0x00,0x00}, // 6 {0x01,0x71,0x09,0x05,0x03,0x00,0x00,0x00}, // 7 {0x36,0x49,0x49,0x49,0x36,0x00,0x00,0x00}, // 8 {0x06,0x49,0x49,0x29,0x1E,0x00,0x00,0x00}, // 9 };显示单个数字的函数就是把对应数字的8个字模逐行写入:
void MAX7219_DisplayNumber(uint8_t num) { uint8_t row; if (num > 9) return; for (row = 0; row < 8; row++) { MAX7219_SendData(row + 1, font_0_9[num][row]); } }这里row + 1是因为寄存器地址0x01对应第1行,0x08对应第8行。主循环里做一个计数演示:
int main(void) { uint8_t i; GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); MAX7219_Init(); while (1) { for (i = 0; i <= 9; i++) { MAX7219_DisplayNumber(i); delay_ms(1000); } } }注意我把GPIO配置成了推挽输出,速度50MHz,这个速度等级在大多数情况下够用。如果你用的是开漏输出,DIN无法主动拉高,需要外部上拉电阻,否则点阵会闪。主循环里每个数字停留1秒,方便观察字模有没有取反、倒置。delay_ms的具体实现见下一章,这里先当作阻塞延时用。
4. 显示0-9的完整工程结构:时钟、延时与主循环怎么编排
4.1 时钟配置:为什么直接用内部8MHz也行
很多教程一上来就配外部晶振、PLL倍频到72MHz。但对于MAX7219这种工作频率最高10MHz的芯片,内部8MHz的HSI时钟完全够用,而且能省掉外部晶振起振等待的麻烦。如果你用的是最普通的F103C8T6核心板,板上那颗8MHz晶振在低温或接触不良时会导致启动卡死在HSE超时检测里,用内部时钟反而更稳。我建议入门阶段直接用SystemInit()默认的HSI 8MHz运行,等把点阵调通后再去碰PLL。
但要注意一个细节:如果用8MHz主频,上一章代码里的__NOP()延时大概在125ns左右,虽然仍大于手册要求的50ns,但裕量没那么宽了。如果机器跑起来偶尔花屏,优先给GPIO翻转之间插入更明显的延时,比如用一个while空循环延时1~2微秒。软件SPI本身不需要追求高速,能显示就行,反正在点阵这种低压差场景里,慢比快可靠。
4.2 延时函数:空循环与SysTick,哪个更适合这个项目
延时直接影响数字切换节奏。STM32F103C8T6的HAL库自带HAL_Delay,标准外设库没有,所以不少工程会自己写一个delay_ms。常见做法有两种:一是简单的空循环,二是SysTick中断。对于"每秒切换一次数字"这个需求,空循环足够,而且不占中断。代码如下:
void delay_ms(uint32_t ms) { volatile uint32_t i, j; for (i = 0; i < ms; i++) { for (j = 0; j < 8000; j++) { // 空循环,8MHz主频下约1毫秒 } } }这个8000是拍脑袋估的,不同编译优化等级下误差很大。更严谨的做法是用SysTick做精确延时:
volatile uint32_t sys_tick = 0; void SysTick_Handler(void) { sys_tick++; } void delay_ms(uint32_t ms) { uint32_t start = sys_tick; while ((sys_tick - start) < ms); }使用SysTick时,你要在main里先初始化:SysTick_Config(SystemCoreClock / 1000),这样每次tick就是1ms。好处是延时期间CPU空转但中断保持精准,坏处是它占用了SysTick中断,如果你后面要用HAL_GetTick()或者HAL_Delay()可能会有冲突。我个人的习惯是:工程只用标准外设库时用SysTick,混合HAL库时直接用HAL_Delay,不自己再造轮子。对于点阵显示这种非实时任务,空循环反而是最不容易出问题的。
4.3 字模表的组织方式与查表显示:不要用switch-case
初学者常把显示逻辑写成:
switch(num) { case 0: SendData(0x01, 0x3E); ... break; case 1: ... }这种写法在显示单个数字时没什么问题,但一旦要做到滚动、秒表计时、多块点阵级联,代码会膨胀到没法看。正确做法是像第三章那样把字模定义成二维数组,用数字本身作为下标。这背后的思路是"数据与逻辑分离":字模表是数据,显示函数是逻辑,逻辑只需要遍历数组,不需要关心数字长什么样。即使后面要显示字母A-F,也只是往数组里多塞几行,主函数一行都不用改。
另外,字模表的存储建议用const修饰,让编译器把它放进Flash只读区。F103C8T6有64KB Flash,20KB RAM,一张字模表才80字节,放Flash里不占RAM。如果你在代码里写uint8_t font[10][8]不加const,它会被复制到RAM里去,虽然RAM也够,但这是嵌入式的基本素养问题。
4.4 下载与调试:ST-Link和串口打印,哪个先做
在这个项目里,最忌讳的是还没写显示逻辑就急着调串口。MAX7219点阵本身就是调试输出设备,画面对错直接反映数据对错。我的习惯是先把整个工程编译烧录,如果点阵全亮或全灭,先检查硬件连接和电源;如果能显示数字但形状不对,再考虑字模方向;只有一种情况值得动用串口——你需要同时看多个变量的状态,比如行列扫描的循环变量和发送的地址。
使用ST-Link烧录时要注意:F103C8T6的SWD引脚是PA13和PA14,如果你把点阵的DIN、CLK接到这俩引脚,第一次烧录可以正常,第二次就可能在连接阶段直接失败,因为点阵线缆干扰了SWD时序。我吃过这个亏,把CLK硬挤到PA14上,结果每次烧程序都得按住复位键。所以引脚分配时尽量避开PA13/PA14和PA9/PA10(串口)。如果你已经遇到了,解决办法是把点阵线缆从下载器上脱离,烧完再插回去。
5. 8X8点阵显示的5个避坑记录:从"花屏"到"亮度不均"的排障清单
5.1 上电乱码花屏,点随机亮
现象:程序烧进去后点阵上出现不规则的亮点,完全看不出数字,重启后乱码位置还不一样。
原因:MAX7219上电后各寄存器值不确定,尤其是行数据寄存器,可能是芯片内部残留的随机值。另一个原因是初始化代码里没清空行数据,或者初始化函数根本没在写数字之前执行。
解决:严格按第三章的顺序执行初始化——先关Shutdown(0x0C写1),再设扫描和亮度,最后逐个把0x01到0x08写0。这个顺序不能乱,尤其是"清空行数据"必须在显示测试关闭之后做,否则你清空了,显示测试又全点亮,看起来还是花的。
5.2 数字显示倒置或左右镜像
现象:数字"6"看起来像"9",或者数字上下颠倒。
原因:字模的位方向和点阵模块的物理行列接法不一致。我前面提过,不同的8x8点阵模块,DIG和SEG对应行的关系不一定相同。比如我的模块DIG0接的是V1列而不是Row0,那你按行优先传数据时,每行数据被驱动到了列上,自然就镜像了。
解决:先别急着改代码。用一根杜邦线把VCC短暂接到某个LED的正极,观察这个LED在电路板上的位置,确认实际行、列定义。然后调整字模方向:上下颠倒就把字模数组按行倒序存,左右镜像就把每个字节的位序反转184。这一步是最耗时间的,但也是最该自己动手验证的,因为没人能替你猜模块的丝印有没有印错。
5.3 亮度不均匀,某一行明显偏暗
现象:数字显示出来是完整的,但最上面一行或最下面一行亮度明显比其它行暗,尤其在显示"8"这种满行字符时非常明显。
原因:一是亮度寄存器0x0A设置得太低,导致行驱动电流下降,MAX7219内部恒定电流源在小电流档下一致性变差;二是电流设置电阻Rext阻值过大,让峰值电流超过芯片额定,导致扫描不均匀。
解决:用万用表量一下模块上标识为Rset的电阻,常见值是10K欧。如果想让亮度更均匀,把亮度寄存器调到0x0F,再降低Rext到9.1K或8.2K,但注意此时电源电流会增大,需要检查5V电源功率。还有一点,扫描边界寄存器如果只写了0x04,芯片会只扫4行,另外4行完全不亮,这也会被误认为"亮度不均"。
5.4 发送数据后实际显示错位一行
现象:我想显示数字"0",但第1行数据跑到了第2行上,第8行空着,整个数字向下移了一行。
原因:初始化时地址写错了。比如我把清空行数据的循环写成for(row = 0; row < 8; row++) SendData(row, 0);,这里row=0对应寄存器地址0x00,而MAX7219的0x00不是行寄存器,是No-op指令,数据被丢弃。行寄存器从0x01开始,所以循环从0开始就会整体错位。
解决:把地址偏移改成SendData(row + 1, data)。这个坑几乎人人都会踩,因为数组下标从0开始是编程直觉,但寄存器地址从1开始是芯片设计,两者差1。我的教训是写任何驱动代码前,先把功能寄存器地址表抄在注释里,接在函数定义后面,写循环时紧盯地址表格。
5.5 长线连接时间歇性闪烁
现象:杜邦线一长(超过20cm),显示画面开始随机闪烁,用手碰到杜邦线更明显,但缩短线之后完全正常。
原因:这是信号完整性问题。DIN、CLK的上升沿过缓,加上导线电感,在CLK上升沿附近DIN电平抖动,导致移位寄存器采到错误数据。STM32F103C8T6 GPIO默认输出摆率有限,长线时问题更严重。
解决:三个办法组合着用。第一,把GPIO输出速度等级调到GPIO_Speed_50MHz,提高输出摆率;第二,在CLK和DIN线上各串一个33欧电阻,在模块靠近电源处加100nF解耦电容;第三,降低软件SPI速率,每次CLK翻转之间加__NOP()延时。我现在调试这类小模块时都是两条短杜邦线,长度控制在10cm以内。
6. 从0-9到滚动字幕:让这块8X8点阵真正用起来
数字静态显示只是第一步,MAX7219真正有看点的是滚动显示。要做到平滑左移,思路是用一个更大的字模缓冲,比如要显示"1234",先构造一个32x8的缓冲区,把四个数字的字模并排填进去,然后每次把缓冲区的第n到n+7列作为一帧输出,n从0递增到25,就能形成滚动效果。这个算法不复杂,但要注意F103C8T6的RAM才20KB,别把缓冲区声明成很大的二维数组,一般256字节足够。
const uint8_t text_buffer[32] = { 0x3E,0x51,0x49,0x45,0x3E,0x00, // "0" 0x00,0x42,0x7F,0x40,0x00,0x00, // "1" // 后面继续填"2"和"3"的字模,每个数字占8字节 }; void MAX7219_ScrollDisplay(void) { uint8_t i, row; for (i = 0; i <= 25; i++) { for (row = 0; row < 8; row++) { uint8_t frame = 0; if (i + row < 32) { frame = text_buffer[i + row]; } MAX7219_SendData(row + 1, frame); } delay_ms(80); } }这里frame直接取缓冲区的列数据,每帧左移一列,视觉效果就是数字向左平滑滚动。80ms的帧间隔是我觉得比较舒服的速度,太快了眼睛跟不上,太慢了显得僵硬。这个滚动函数放在主循环里,再配合一个按键检测,就能做一个按键切数字、长按滚动的演示,课设答辩时比单纯闪数字有说服力得多。
另一个值得玩的进阶是多块MAX7219级联。把第一块的DOUT接到第二块的DIN,两个芯片共享CLK和CS,发送数据时先发最后一块的数据,再发第一块的,因为MAX7219的移位寄存器是串行链。级联之后,8x8点阵就能拼成8x16或8x32的横幅,显示原理和单块完全一样,只是数据发送顺序要反过来。我建议你先把单块调稳,再去碰级联,因为级联的寄存器写入顺序反过来的那一步,也是新手最容易翻车的地方,一条数据发错整条横幅都会乱。
最后说个调试习惯:每次改完字模或滚动逻辑,先烧一个"全屏亮灭测试"程序,让64个LED全部亮1秒、全灭1秒,循环几次。这一步能快速排除硬件断线和芯片损坏的可能性,比直接调字模省时间得多。我自己现在写任何点阵项目都会先留这个测试函数在工程里,等项目稳定了再删。希望这篇能帮你把这块8X8点阵真正跑起来,后面再做电子钟、温度显示或者小游戏,思路都是一样的:处理好SPI时序,管好寄存器,剩下的就是字模表的想象力了。
本文还有配套的精品资源,点击获取