ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

STM32F103驱动16x16点阵:从74HC595到动态扫描完整指南

STM32F103驱动16x16点阵:从74HC595到动态扫描完整指南 简介面向STM32入门学习者的16×16点阵显示工程基于STM32F103标准库实现完成了汉字和图形的点阵显示及上下左右移动功能。工程自带字库文件可自行拷贝字形到字库数组并附有点阵显示原理图便于对照硬件理解驱动逻辑。代码采用较直观的原始移位方法虽不够精简但逻辑清晰适合初学者阅读和二次修改。包内共335个文件大小15.57MB。其中包含33个C源码、36个头文件覆盖STM32标准外设库的定时器、USART、ADC等模块另有uvprojx工程文件、hex/axf烧录文件、PDF原理图及编译中间文件可直接用Keil打开编译或烧录验证。资源已有3984人学习对于想快速上手点阵显示、理解底层寄存器操作和字库取模原理的读者是一份不错的参考。1. 从一块裸屏到完整显示STM32F103 驱动 16x16 点阵到底难在哪很多人第一次拿到 16x16 点阵模块时第一反应是“就 256 个灯单片机引脚这么多直接接上不就行了”。真上手就会发现STM32F103 的 GPIO 看起来不少但一块 16x16 点阵要占 32 个引脚如果做成级联或者还要加按键、传感器IO 立刻就不够用更别说全亮时电流轻松超过 500mA直接灌进芯片引脚芯片发热、亮度不均、甚至烧引脚都是常事。这篇文章要解决的就是从一块裸屏到稳定显示“汉字、数字、简单动画”的完整链路——硬件选型、驱动电路、扫描时序、代码框架和调试方法一次讲透。我假定你手里有一块常见的 16x16 点阵模块主控是 STM32F103C8T6 或兼容型号开发环境是 Keil 或 STM32CubeIDE。不管是用 MAX7219 级联、74HC595 级联还是直接 GPIO 扫描最终要过的关都一样引脚不够怎么办、亮度怎么均匀、刷新率怎么提上去、发热怎么降下来。这篇笔记按我实际做过的方案来讲新手可以照着接线和抄代码熟手可以重点看参数取舍和避坑部分。2. 16x16 点阵的驱动方案选型GPIO 直驱、MAX7219 级联、74HC595 级联怎么选2.1 三种方案的硬件差异与适用场景16x16 点阵的驱动本质是“动态扫描”——每次只点亮一行或一列快速轮换所有行利用人眼视觉暂留形成完整画面。但“每次点亮一行”这句话背后不同方案的成本和代码量天差地别。GPIO 直驱是最直观的思路16 行接 16 个引脚16 列接 16 个引脚一共 32 个 GPIO。STM32F103C8T6 的 GPIO 总数是 37 个理论上够用但你会发现几个问题第一引脚全部被占满系统功能没法扩展第二每个引脚直接驱动 LED 需要加限流电阻32 个电阻占板面积不小第三全亮时如果列线由 GPIO 直接灌电流单个引脚的绝对最大额定值通常是 25mA16 个 LED 同时亮就是 16 倍电流引脚必烧。所以直驱适合“演示用、亮度要求低、点亮时间短”的场景不适合做产品。MAX7219 级联是另一种常见方案。MAX7219 是 8x8 LED 驱动芯片内部自带扫描电路、电流控制和 BCD 解码只需要 3 根线DIN、CLK、LOAD就能串起多颗芯片。16x16 需要 4 颗 MAX7219两两拼成一行。这个方案的好处是主控代码极其简单——往寄存器里写数据就行刷新、扫描、限流全是芯片在做而且 MAX7219 的段电流由外部电阻设定可以做到亮度非常均匀。缺点是成本高一些4 颗芯片加 PCB 布线比 74HC595 方案贵而且 MAX7219 的最大段电流有限大尺寸点阵会偏暗。74HC595 级联是我个人最推荐、也是这次重点展开的方案。74HC595 是串行输入、并行输出的移位寄存器一颗可以锁存 8 位数据级联后可以用 3 根线DS、SHCP、STCP驱动任意多的输出。16x16 点阵一般只需要 4 颗 74HC595两颗做行选两颗做列数据。这个方案成本低、代码可控性强而且扫描时序完全由你掌握做动画和灰度都更灵活。2.2 74HC595 级联驱动的电路结构用 74HC595 驱动 16x16 点阵标准接法是行扫、列驱两个 74HC595 负责行选16 行另两个负责列数据16 列。行选输出的信号经过三极管或 ULN2803 放大后接点阵的行线列输出直接或经限流电阻接列线。为什么行要加驱动因为点阵的每一行有 16 个 LED全亮时一路行电流是 16 x 单灯电流假设每个 LED 5mA就是 80mA。74HC595 的单个输出脚最大灌电流也就 20mA 左右直接驱动会过热甚至烧毁而且电压会被拉低亮度明显不均。常见的做法是74HC595 输出接 ULN2803达林顿管阵列单路最大 500mAULN2803 输出接行线列线由另外两个 74HC595 直接输出经过 100Ω 左右的限流电阻接列线。从 STM32F103 到 74HC595 只需要 3 个 GPIODS串行数据、SHCP移位时钟、STCP锁存时钟。这就是这个方案最舒服的地方——主控只需要 3 个引脚剩下的大把引脚留给按键、传感器、通信模块。我一般还会把 74HC595 的 OE输出使能引脚接到 STM32 的一个 PWM 引脚这样可以软件调亮度后面会细说。提示如果点阵是共阳的行线接正极列线经限流电阻接到 74HC595 输出如果共阴则反过来。买模块时一定要先确认是共阳还是共阴代码里的扫描极性完全不同。2.3 三方案对比我按什么条件选型方案占用主控引脚驱动能力代码复杂度成本适用场景GPIO 直驱32 个弱需外部驱动低最低学习演示、临时验证MAX7219 级联3 个中芯片自带限流极低高快速开发、需要灰度74HC595 级联3 个 1 个 PWM强可扩展 ULN2803中低产品化、大屏、动画我在实际项目里对三个方案的排序是玩票用 GPIO 直驱赶工期用 MAX7219做正经东西用 74HC595 级联。如果你做的是一块要 7x24 小时运行的显示设备或者需要把画面亮度调得非常均匀74HC595 是唯一不容易翻车的路。MAX7219 看起来省事但多颗级联时容易出现芯片间温度差异导致的亮度不一致而且它的扫描频率固定想自己控制刷新节奏反而不方便。3. 从原理图到点亮第一行STM32F103 驱动 74HC595 的完整代码实现3.1 引脚分配与初始化代码先把引脚分配定下来。我习惯把 STM32F103C8T6 的这几个引脚用于点阵驱动引脚功能说明PA0DS串行数据输出PA1SHCP移位时钟上升沿移入一位PA2STCP锁存时钟上升沿把移位寄存器数据锁存到输出PA3OE输出使能低电平有效可接 PWM这样安排的理由有两个PA0-PA3 在同一个 GPIOA 端口上可以用 GPIOA 的 BSRR 寄存器一次性操作速度比单独操作 ODR 快不少同时这几个引脚在 36 脚封装的 C8T6 上都引出来了不需要飞线到芯片肚子底下。void DotMatrix_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; // 高速输出保证移位时钟边沿够陡 GPIO_Init(GPIOA, GPIO_InitStructure); }这段代码把 PA0-PA3 配置为推挽输出速度选 50MHz。推挽输出的原因是 74HC595 的输入引脚需要明确的电平驱动开漏模式需要外部上拉没必要50MHz 速度是为了移位时钟的上升沿足够陡峭如果 GPIO 速度配得太低时钟边沿会变缓在较高移位频率下可能造成数据错位。注意OE 引脚我建议在硬件上接一个 10kΩ 上拉电阻到 VCC。这样 MCU 还没初始化时OE 为高电平74HC595 输出处于高阻态点阵不会出现乱亮的情况。别小看这个电阻很多“上电瞬间闪一下”的玄学问题就是它解决的。3.2 向 74HC595 发送一字节数据的底层函数74HC595 的数据传输协议很简单每个 SHCP 上升沿DS 引脚上的电平被移入移位寄存器的一位发送完 8 位后给 STCP 一个上升沿数据从移位寄存器锁存到输出引脚。下面是发送单字节的标准实现void HC595_SendByte(uint8_t dat) { for (uint8_t i 0; i 8; i) { if (dat 0x80) { // 从最高位开始发送 GPIO_SetBits(GPIOA, GPIO_Pin_0); // DS 1 } else { GPIO_ResetBits(GPIOA, GPIO_Pin_0); // DS 0 } GPIO_SetBits(GPIOA, GPIO_Pin_1); // SHCP 上升沿移入一位 GPIO_ResetBits(GPIOA, GPIO_Pin_1); dat 1; // 左移准备下一位 } }这个函数的逻辑很简单判断当前最高位是 0 还是 1设置 DS 电平然后给 SHCP 一个上升沿数据被锁入移位寄存器。选择从最高位开始发送是因为级联时先发送的数据会被推到更后面的芯片符合常见的“数组低字节对应屏幕左边”的习惯。参数说明dat 是要发送的 8 位数据比如 0xAA 就是 10101010对应点阵一行的第 1、3、5、7 列亮。如果要调整发送顺序比如从最低位开始只需要把判断条件改成if (dat 0x01)并把dat 1改成dat 1。这里我踩过的坑是不同厂家的点阵模块行线和列线的映射顺序不一致有的第 0 列在左边有的在右边这就要靠后面讲的“点亮单点定位”函数去校准。3.3 16x16 点阵的扫描函数行选 列数据同时刷新单个字节发送是地基真正的 16x16 显示要靠“行扫描”来完成。每次只点亮一行把这一行 16 列的数据发出去然后切换到下一行周而复始。整个 16x16 的显示数据放在一个二维数组里disp_buf[row][col]表示第 row 行、第 col 列是否点亮。uint8_t disp_buf[16][2]; // 每行 2 个字节高字节对应左 8 列低字节对应右 8 列 void DotMatrix_Scan(void) { for (uint8_t row 0; row 16; row) { // 1. 先发列数据两个 74HC595 级联先发低字节右半屏再发高字节左半屏 HC595_SendByte(disp_buf[row][1]); // 右 8 列 HC595_SendByte(disp_buf[row][0]); // 左 8 列 // 2. 再发行选数据高 8 位对应上半屏低 8 位对应下半屏 uint16_t row_sel (1 row); // 第 row 位为 1 HC595_SendByte((uint8_t)(row_sel 8)); // 行选高字节 HC595_SendByte((uint8_t)(row_sel 0xFF)); // 行选低字节 // 3. 锁存让所有数据同时出现在输出引脚上 GPIO_SetBits(GPIOA, GPIO_Pin_2); GPIO_ResetBits(GPIOA, GPIO_Pin_2); // 4. 短暂延时让这一行的 LED 点亮一小段时间 Delay_us(100); } }这段代码是核心我拆开讲。第一步发列数据用的是“先右后左”的顺序因为两片 74HC595 级联时先发送的数据会从第一片溢出到第二片所以先发的字节最终到达右侧那片。第二步发行选数据1 row生成一个 16 位的行选码第 row 位为 1代表选中第 row 行。行选信号经过 ULN2803 后会把这行的公共端拉到有效电平这行的 LED 才有电流通路。第三步 STCP 锁存保证列数据和行数据同时输出——如果不锁存你会发现画面有“拖影”或“裂痕”因为行和列的数据不同步会短暂点亮错误的点。第四步延时控制每行点亮的时间这个值决定了刷新率和亮度。参数怎么调Delay_us(100)代表每一行点亮 100 微秒16 行轮完一圈是 1600 微秒刷新率约 625Hz远超肉眼感知的临界频率画面不闪。如果改成 10001ms刷新率降到约 62Hz仔细看能感觉到闪烁。这就是“刷新率和亮度”的矛盾延时长每行点亮时间长亮度高但刷新率下降延时短则相反。我的经验值是 100-200 微秒比较均衡具体的后面避坑章节再展开。3.4 点阵的“点”到底怎么操作坐标映射与取模格式扫描函数已经能“整屏刷”了但用户总得有个入口去设置某个点亮不亮。这里的关键是建立“逻辑坐标”到“物理行/列”的映射。16x16 点阵的坐标定义(x, y)中 x 是列0-15从左到右y 是行0-15从上到下。显示缓冲区disp_buf的索引关系是y对应第一维行x/8对应第二维哪个字节x%8对应字节内的位。void DotMatrix_SetPoint(uint8_t x, uint8_t y, uint8_t on) { if (x 15 || y 15) return; // 越界保护 uint8_t byte_index x / 8; // 0 表示左半屏1 表示右半屏 uint8_t bit_index x % 8; // 0-7对应字节内的位 if (on) { disp_buf[y][byte_index] | (0x80 bit_index); // 置 1 } else { disp_buf[y][byte_index] ~(0x80 bit_index); // 清 0 } }这里有一个容易踩的坑位顺序。如果 HC595_SendByte 是从最高位开始发送那么0x80 bit_index这个顺序正好对应左到右排列如果你改成从最低位发送这里就必须改成0x01 bit_index。我在第一次做的时候没注意这个匹配关系结果显示出来的字是左右镜像的。这个问题排查了很久最后是写了一个“单个点循环点亮”的测试函数逐个点确认物理位置才发现是位序搞反了。void DotMatrix_TestPoint(void) { for (uint8_t y 0; y 16; y) { for (uint8_t x 0; x 16; x) { DotMatrix_SetPoint(x, y, 1); for (int k 0; k 500; k) DotMatrix_Scan(); // 持续显示肉眼可见 DotMatrix_SetPoint(x, y, 0); } } }这段测试代码会从左到右、从上到下逐点点亮每个点停一小会儿。运行一遍你就能看出硬件连接和软件位序有没有一致。我建议拿到板子的第一件事就是跑这个测试确认坐标映射没问题再去做字库和动画否则后面所有显示错误都很难定位。3.5 显示汉字和数字取模工具的格式选择与转换16x16 点阵最常见的需求是显示汉字。一个 16x16 的汉字轮廓刚好能放进这个分辨率的“格子”里所以叫 16 点阵字库。取模工具比如常见的字模软件输出的数据格式有两种列行式和行列式还有按字节排列的“横向取模”和“纵向取模”之分。对于我们的驱动方式——按行扫描、发送 2 字节列数据——应该选“横向取模、字节从上到下、位从左到右”的格式。以汉字“中”为例取模软件输出通常是这样的 32 个字节const uint8_t font_zhong[32] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFC, 0x40, 0x04, 0x40, 0x04, 0x40, 0x04, 0x7F, 0xFC, 0x40, 0x04, 0x40, 0x04, 0x40, 0x04, 0x7F, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };这些数据的排列方式是每行 2 个字节一共 16 行正好对应disp_buf[16][2]。要把字库数据塞进显示缓冲直接用 memcpy 就行void DotMatrix_ShowChar(const uint8_t *font_data) { for (uint8_t row 0; row 16; row) { disp_buf[row][0] font_data[row * 2]; // 左半屏 disp_buf[row][1] font_data[row * 2 1]; // 右半屏 } }这里的关键是取模格式必须和这个函数匹配。如果你取模时选了“纵向取模”数据排列就是先左 8 列从上到下 16 字节、再右 8 列 16 字节那么row * 2的取法就全错了显示出来是一片乱码。我的习惯是取模工具里统一选“横向取模、逐行式、16 行、每行 2 字节”并且把取模设置截图存到工程 docs 目录里。做多套字库时这个截图能救你命。4. 显示效果调试刷新率、亮度、残影和发热的 4 个避坑实战4.1 整个屏幕亮度不均左边亮右边暗现象显示一个全屏的矩形时明显感觉到左半屏比右半屏亮或者上半屏比下半屏亮。原因这是电流分配不均的典型表现。列数据由 74HC595 直接驱动74HC595 输出引脚存在导通电阻但更主要的原因是 PCB 走线宽度不够长走线上的压降让远端 LED 的供电电压偏低。还有一种常见情况两个列驱动 74HC595 中一个靠近电源输入端一个在级联链的末端末端芯片的电源引脚如果只靠级联的 VCC 走线供电电流一大电压就被拉低。解决第一检查 PCB 上点阵模块的 VCC 和 GND 走线尽量加宽或者直接用覆铜第二在两个列驱动 74HC595 的电源引脚旁边各加一个 100nF 去耦电容位置尽量靠近芯片第三如果用的是模块加杜邦线的接法换短一点的线杜邦线电阻不小。软件上也能补救一部分给左右半屏分别设一个亮度校正系数在列数据输出前做一次映射但这是治标不治本硬件的电源供应才是根本。4.2 滚动文字时出现“拖影”和“鬼影”现象显示静态汉字没问题但一旦左右滚动字的边缘会出现淡淡的残影像拖尾一样。严重时关闭显示后屏幕上还能隐约看到之前的图案。原因一是扫描频率不够LED 灭掉后有余晖但人眼对运动的低刷新率会更敏感所以滚动时残影被放大二是行选和列选切换时存在“中间态”串扰——行切换的瞬间前一行的列数据还没来得及清空会导致几个微秒的错误点亮。人眼平时察觉不到这几点但动态画面会放大它。解决第一步把Delay_us从 100 调到 60 左右提高刷新率第二步在行切换时先清空列数据再换行顺序是发一个全 0 的列数据并锁存 → 切换行选 → 发送新行数据并锁存。这个过程叫“消隐”是 LED 扫描驱动里最常见的时序处理。void DotMatrix_Scan_WithBlanking(void) { for (uint8_t row 0; row 16; row) { // 消隐先输出全 0 列数据防止上一行残余数据串到下一行 HC595_SendByte(0x00); HC595_SendByte(0x00); GPIO_SetBits(GPIOA, GPIO_Pin_2); GPIO_ResetBits(GPIOA, GPIO_Pin_2); // 切换行选 uint16_t row_sel (1 row); HC595_SendByte((uint8_t)(row_sel 8)); HC595_SendByte((uint8_t)(row_sel 0xFF)); // 发送新的列数据 HC595_SendByte(disp_buf[row][1]); HC595_SendByte(disp_buf[row][0]); // 锁存点亮当前行 GPIO_SetBits(GPIOA, GPIO_Pin_2); GPIO_ResetBits(GPIOA, GPIO_Pin_2); Delay_us(60); } }消隐的原理很简单行切换前把所有列输出清零这样即使行选信号正在切换、中间有短暂的导通也没有电流通路自然不会有错误点亮。付出的代价是多发了两个字节扫描时间变长了一点但换来的是干净的画面非常值得。4.3 STM32F103 芯片发热严重甚至引脚烫手现象系统运行几分钟后用手摸 STM32F103 表面明显发热有时候特定引脚摸上去发烫。原因这是 GPIO 被灌电流了。看电路图列线接在 74HC595 输出上如果某个时刻行选有效、列输出为低电平共阳接法LED 导通电流从电源经过 LED、限流电阻、74HC595 流入地这条路是正常的。但如果你用的是 GPIO 直驱方案或者 74HC595 的 OE 引脚悬空上电瞬间 74HC595 输出状态不确定可能导致行线和列线之间短路电流直接灌进 MCU 引脚。解决第一确认 74HC595 的 OE 不要悬空接上拉电阻到 VCC默认关闭输出第二检查限流电阻是否正确接入。每个 LED 串联一只 100-220Ω 的电阻能限制单灯电流在 5-15mA 之间。第三如果你用的是 GPIO 直驱方案千万不能省限流电阻。第四即使方案没问题MCU 正常工作温度应该是微温如果烫手大概率是某个引脚被外部电路强灌电流用万用表逐个量 GPIO 引脚的电流找到异常的那一路。4.4 数据更新后画面闪烁一下然后又变正常现象调用DotMatrix_SetPoint或更新字库后屏幕上出现一次明显的闪烁像是整个画面闪断再恢复。原因在扫描函数运行过程中主循环直接修改了disp_buf数组。当扫描到第 N 行时新数据已经写入第 N1 行这一帧的画面就是“新旧混杂”的看起来像是撕裂或闪断。这个问题在静态显示时不影响因为下一帧就正常了但如果你在更新动画或响应按键事件就会频繁出现。解决使用双缓冲。定义两个缓冲区disp_buf和disp_buf_next主循环或中断里只修改disp_buf_next扫描函数只读disp_buf。在扫描的帧间隙比如扫描完第 16 行后把disp_buf_next拷贝到disp_buf中。uint8_t disp_buf[16][2]; uint8_t disp_buf_next[16][2]; volatile uint8_t frame_flag 0; void DotMatrix_Scan_WithDoubleBuffer(void) { for (uint8_t row 0; row 16; row) { // ... 消隐、行选、列数据输出全部读 disp_buf[row] } // 一帧扫描结束后检查是否有新数据 if (frame_flag) { memcpy(disp_buf, disp_buf_next, sizeof(disp_buf)); frame_flag 0; } }这个做法在很多显示驱动里是标配。注意frame_flag要定义成volatile因为它会在主循环里修改、在扫描函数里判断。双缓冲的代价是 32 字节的 RAM对 STM32F103 的 20KB RAM 来说微不足道但换来的画面干净程度是质变。5. 进阶玩法用定时器中断驱动扫描让 CPU 彻底解放出来5.1 为什么要把扫描从主循环搬进定时器中断前面所有的代码示例里DotMatrix_Scan都在主循环里调用。这在显示静态内容或简单动画时没问题但一旦你的系统还要做按键检测、传感器读取、串口通信主循环里各个任务的耗时就会影响扫描的稳定性——扫描被耽误 1ms这一行的点亮时间就变成 1.1ms其他行还是 0.1ms亮度立刻不均匀。这是“主循环扫描”方案的致命伤。解决办法是把扫描搬进定时器中断。设定一个定时器定时周期等于每行点亮的时长比如 100 微秒中断服务函数里执行行切换和数据锁存主循环就彻底解放了。显示系统变成一个后台任务你只管修改disp_buf的数值屏幕自动刷新CPU 还有大把空闲跑其他逻辑。STM32F103 的定时器资源很多用 TIM3 做这个工作最合适TIM3 挂在 APB1 总线上72MHz 主频下可以精确分频到任意周期。还要注意中断优先级——如果系统里还有其他中断TIM3 的优先级必须高于可能被阻塞的任务但低于紧急事件比如串口接收超时。5.2 定时器配置与中断服务函数的完整代码volatile uint8_t current_row 0; // 当前扫描到第几行 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update)) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); // 消隐 HC595_SendByte(0x00); HC595_SendByte(0x00); HC595_Latch(); // 切换行选 uint16_t row_sel (1 current_row); HC595_SendByte((uint8_t)(row_sel 8)); HC595_SendByte((uint8_t)(row_sel 0xFF)); // 发送列数据 HC595_SendByte(disp_buf[current_row][1]); HC595_SendByte(disp_buf[current_row][0]); HC595_Latch(); // 下一行 current_row; if (current_row 16) { current_row 0; // 在这里可以触发一帧结束标志比如切换双缓冲 } } } void DotMatrix_TIM3_Init(void) { TIM_TimeBaseInitTypeDef TIM_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_InitStructure.TIM_Period 100; // 自动重装载值 TIM_InitStructure.TIM_Prescaler 7200 - 1; // 预分频72MHz / 7200 10kHz TIM_InitStructure.TIM_ClockDivision 0; TIM_InitStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_InitStructure); NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; // 抢占优先级设低一点 NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); TIM_Cmd(TIM3, ENABLE); }参数说明TIM_Period 100加上TIM_Prescaler 7200-1意味着定时器时钟是 72MHz / 7200 10kHz也就是每 0.1ms 产生一次更新中断正好对应每一行的点亮时间 100 微秒。16 行扫描完一轮需要 1.6ms刷新率约 625Hz肉眼完全无闪烁。如果你想调暗一点可以把 Period 调大比如 200但刷新率会降到约 312Hz仍然够用想更亮就调小但注意刷新率太低会闪。HC595_Latch()是我把锁存操作封装成的一个函数内容就是 STCP 置高再置低。中断函数里调用这个封装代码可读性好很多不要直接裸写 GPIO 操作。提示中断服务函数里要尽量少做事。HC595_SendByte 是循环发 8 个 bit里面有 GPIO 操作中断里调用是没问题的但你不能再塞入 memcpy 这种耗时操作。双缓冲的拷贝最好放在主循环里做中断只置一个标志位。5.3 中断扫描带来的新坑优先级和阻塞排查用定时器中断后会遇到新一类问题主循环里某个操作关了中断或阻塞了中断太久导致扫描停顿屏幕闪烁。最常见的坑在Delay_ms函数上——如果你用的是阻塞式延时比如基于 SysTick 的简单循环而这个延时函数里又关了中断TIM3 中断就没法响应。排查方法很直接写一个测试程序主循环里只做一件事——翻转一个 GPIO比如 PA5用示波器或逻辑分析仪看这个 GPIO 的翻转波形是否均匀。然后逐步加入其他功能按键扫描、I2C 读取等每加一个就观察波形有没有毛刺或停顿。波形抖动的地方就是阻塞中断的元凶。另一种典型坑是某个外设的中断优先级比 TIM3 高而且它的中断服务函数里进了死循环或者延时等待。比如串口接收中断里用了阻塞式等待发送完成的代码一旦数据没及时发出去中断就卡住了TIM3 完全被饿死。解决方法给所有中断的抢占优先级做一个规划TIM3 设成优先级 1仅次于紧急事件其他非紧急中断设成 2 以上串口发送改成中断式或 DMA 式不要在中断里等待。5.4 用 PWM 控制 OE 引脚实现软件调光74HC595 的 OE 引脚是低电平使能输出这给了我们一个天然的调光通道如果 OE 引脚不是固定接低电平而是接 PWM 信号那么 LED 的有效点亮时间会被 PWM 的占空比压缩亮度随之变化。调制的频率要高于扫描频率建议 5kHz 以上否则能看到闪烁。为了做到这一点OE 引脚需要选择 STM32F103 具有定时器 PWM 输出功能的引脚。我常用 TIM2 的 CH1PA0或 TIM3 的 CH2PA1——注意这两个引脚和 DS、SHCP 有冲突所以电路设计时就要做好分配。一个比较顺手的组合是DS 用 PB0、SHCP 用 PB1、STCP 用 PB2把 PA0 留给 TIM2_CH1 做 PWM 输出到 OE。这样 GPIO 操作和 PWM 输出互不干扰。调光的实际意义很大晚上室内环境满亮度刺眼占空比调到 30% 就能舒服很多同时 PWM 调光可以让所有行在时间维度上均匀地降低亮度不会出现像降低限流电阻电流那样的“低亮度下亮度不均”。如果你的应用需要亮度调节这个方案比硬件换电阻优雅得多。void DotMatrix_Brightness_Set(uint8_t percent) { // percent: 0-100对应 PWM 占空比 100%-0% // 注意OE 低电平有效所以亮度越高占空比越低 uint16_t duty (uint16_t)(100 - percent) * 100; // 0-10000 TIM_SetCompare1(TIM2, duty); }这个函数的逻辑当percent 100时duty 0PWM 输出常低OE 常低74HC595 输出始终使能满亮度。当percent 0时duty 10000PWM 输出接近常高OE 常高输出关闭全屏熄灭。实际调试时你会发现 PWM 周期也有讲究如果周期太长比如 100Hz亮度的降低会变成明显的闪烁建议 PWM 频率设在 1kHz 以上人眼感知不到调制频率。6. 显示动画与多场景复用的几个工程技巧做完了静态显示下一步通常是让画面动起来。16x16 的分辨率虽然不高但滚动文字、简单动画、甚至低分辨率的图片轮播都能做得漂亮。这里的核心是把“显示”和“内容生成”彻底解耦中断只管把disp_buf刷到屏幕上主循环负责往disp_buf里填内容。这个架构一旦立住往上加什么功能都是顺势而为。滚动文字是最常见的需求。实现思路是维护一个“显示窗口”的起始列偏移量每次在帧间隙比如每秒 20 次把窗口右移或左移一列再把新的窗口内容写入双缓冲。16x16 的窗口宽度是 16 列如果一段文字宽度是 64 列窗口从 0 滑到 48每一帧更新一次偏移看起来就是平滑的文字滚动。注意滚动的速度和刷新率是两回事刷新率是中断决定的保持 600Hz 以上滚动速度是你主动更新的频率每秒钟移动几列由你控制。动画的实现更直接把动画帧做成字库数组的集合每一帧就是 32 字节用一个定时器比如 TIM4周期性地切换当前帧调用memcpy把帧数据搬进disp_buf_next。需要注意动画帧率不要太高——16x16 的画面内容简单人眼能接受 30fps 就足够流畅每秒 30 帧意味着每帧之间约 33ms这个时间足够把 32 字节拷贝完不会对系统造成明显负担。做多场景复用时我踩过的一个最深的坑是“字库格式不统一”。一开始用的是取模工具的默认格式后来换了一个开源字库格式完全不同显示直接乱码。从那以后我在工程里建了一个font_format.h头文件里面用宏定义写清楚取模方式的约定// 字库格式约定 // 1. 横向取模逐行排列 // 2. 每行 2 字节左半屏高字节在前 // 3. 位序MSB 对应左侧第 0 列 #define FONT_ROW_WORD_SIZE 2 #define FONT_ROW_COUNT 16 #define FONT_TOTAL_SIZE 32任何新接入的字库第一件事就是拿一个已知的字符比如“0”或“中”验证这三点是否匹配不匹配就先写个转换函数统一格式而不是在显示代码里做兼容补丁。这个习惯帮我省了很多次排查乱码的时间你也可以试试。如果你打算走得更远下一步值得做的是把扫描驱动抽成模块显示层和硬件层完全分开。显示层只认识“16x16 的缓冲区”硬件层只负责把缓冲区变成 GPIO 波形。假设某一天你要换更大的屏比如 32x32只需要改硬件驱动显示层的滚动、动画代码一行都不用动。这看起来是软件工程的基本功但很多做单片机的朋友往往忽略这一点把一个 16x16 驱动写成了一坨只属于一块板子的代码。我自己的教训是第一次做完这套系统后大约花了一周时间重构驱动层才让代码能复用到其他项目上如果一开始就按分层写这周时间完全省得掉。希望这些经验能帮你少走几步弯路。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表