尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32 IO不够用?74HC595级联驱动函数库从时序到代码全解析

STM32 IO不够用?74HC595级联驱动函数库从时序到代码全解析 简介这是一份STM32驱动74HC595的串行扩展GPIO函数资源面向嵌入式开发者、电子爱好者以及需要在Keil MDK环境下实现多通道显示控制的工程人员。资源通过软件模拟SPI协议驱动74HC595移位寄存器实现以少量GPIO控制多个并行输出并支持多片级联适用于数码管、LED点阵等场景。压缩包共2个文件包含1个头文件和1个C源文件整体仅1KB结构简洁核心包括HC595寄存器定义、引脚初始化函数、写数据函数以及级联调用接口方便直接添加到现有工程中修改使用。已有3436人学习下载代码量小且注释清晰适合有一定STM32基础、希望快速掌握IO扩展或节省GPIO资源的开发者参考。 STM32驱动74HC595可级联函数库从时序到底层代码一次讲透做嵌入式开发的人迟早会遇到IO口不够用的情况尤其是玩STM32的时候一个项目里LED灯、数码管、按键、传感器全挤在一起引脚资源瞬间捉襟见肘。74HC595这颗8位串行转并行移位寄存器芯片就是解决这类问题的经典方案3根线换8个输出口级联之后还能继续翻倍性价比极高。这篇文章我把自己在实际项目中反复打磨过的STM32驱动74HC595函数库完整分享出来支持任意数量级联代码结构清晰直接复制就能用。不管你是刚接触51单片机转过来的新手还是已经在用STM32做产品的工程师这篇文章都能帮你省下半天时间还能少踩几个我之前踩过的坑。1. 整体设计思路为什么用74HC595而不是其他方案1.1 当IO不够用的时候先别急着换大芯片很多人在项目一开始引脚规划阶段就发现IO不够第一反应是换更大封装的STM32比如从STM32F103C8T6换成STM32F103RCT6引脚数从48跳到64。但换个思路想一想如果你的需求只是多控制几个LED、多驱动几路数码管、多读几个按键这类低速、简单、单向的输出场景用74HC595成本几毛钱3个IO口就能换8个甚至16个输出口完全没必要增加PCB面积和芯片成本。我见过不少项目因为引脚不够而被迫升级芯片结果PCB重画、固件重写整体成本翻了好几倍。而同样是这几个需求用74HC595做端口扩展软件上写个驱动函数就搞定了。从技术对比角度看常用的IO扩展方案有这几种74HC595串转并输出适合单向输出场景速度中等成本最低级联方便。74HC165并转串输入适合按键、拨码开关这类输入采集可以和595配合使用。PCF8574 I2C扩展2根线扩展8个IO双向但速度偏慢适合I2C总线本来就有设备的情况。直接换大芯片简单粗暴但成本最高硬件改动大。1.2 级联需求背后的核心问题标题里强调了“可级联”这是很多入门教程容易忽略的点。单片595只有8个输出位一个项目中用到2片、3片甚至更多的时候非常常见。比如一个4位数码管动态扫描电路需要段选8位位选4位一共12个输出用2片595刚好再比如控制16路LED跑马灯或者驱动8位数码管都需要级联。级联的本质是把多片595的移位寄存器串联起来数据从第一片的DS引脚进入每来一个时钟脉冲就往后移一位最后一片Q7引脚接下一片的DS引脚数据就像流水一样从第一片传到最后一篇。整体上就像一根水管子数据从一头灌进去从另一头流出来中间每一片都是一个存储站。这里最关键的编程点在于级联之后你要发送的数据顺序是反的。第一片595在接收的是最先发送的数据但它在硬件位置上是最靠近MCU的那一片。如果你的数据缓冲区分组对应硬件位置那就需要先把最后一片要显示的数据发出去最后才发第一片的数据。这个细节我当初第一次写的时候就把高低字节搞反了显示出来的效果完全不对。1.3 驱动方案选型GPIO模拟还是硬件SPI驱动74HC595有两种方式第一种也是最通用的用GPIO模拟时序。SCK、RCK、DS三根线分别接3个普通IO口用延时或者空指令来满足建立时间和保持时间。好处是完全不占用SPI外设引脚随意选代码在所有STM32型号上通用。缺点是速度上限取决于GPIO翻转速度和延时设置几百kHz是完全够用的。第二种用硬件SPI。74HC595的时序本质就是SPI模式0SCK接SPI_SCKDS接SPI_MOSIRCK可以用单独的GPIO控制也可以用SPI的NSS引脚。好处是速度高可以跑到几MHz甚至更高同时CPU开销极小。缺点是把SPI外设占用了而且引脚有复用限制不如GPIO方案灵活。我的建议是如果不是对刷新速度有极端要求比如大型LED点阵屏优先用GPIO模拟方案。原因很简单——普通LED、数码管、继电器这类外设对人眼来说几十Hz刷新率就已经很流畅了GPIO模拟完全够用。而且GPIO模拟方案不挑引脚、不挑芯片型号代码移植性极好以后换STM32型号完全不用改驱动。本文提供的函数库就是基于GPIO模拟方案实现的。2. 74HC595核心时序解析与硬件连接2.1 引脚功能与工作时序74HC595内部有两个寄存器8位移位寄存器Shift Register和8位存储寄存器Storage Register。数据在SCK上升沿从DS引脚逐位移入移位寄存器当8个数据位全部移入后在RCK上升沿把移位寄存器的内容一次性锁存到存储寄存器同时并行输出到Q0~Q7引脚。这里有两个引脚非常关键但容易被忽略OEOutput Enable低电平有效。当OE拉高时输出引脚为高阻态芯片相当于与外部隔离。这个引脚可以用来实现全局消隐或者硬件PWM调光。MRMaster Reset低电平有效。当MR拉低时移位寄存器被清零。正常工作时必须接高电平否则芯片不会工作。我遇到过有人焊接好板子发现595完全没输出排查了半天才发现MR悬空了。这种坑其实就一句话的事不用的控制引脚一定要接确定的电平绝不能悬空。数据时序的要求是数据建立时间DS相对于SCK上升沿最好不小于20ns保持时间不小于5nsSTTM32的GPIO翻转速度完全满足。ST库函数或者HAL库操作GPIO单次翻转需要几十到几百ns完全不在话下。2.2 级联硬件连接方法级联硬件的接法非常规律以2片595为例第一片靠近MCU的DS接STM32的数据输出引脚。第一片的Q7引脚接第二片的DS引脚。两片的SCK并联接同一个时钟引脚。两片的RCK并联接同一个锁存引脚。两片的OE、MR分别并联接低电平和电源。工程上有一个很重要的细节SCK和RCK的线要尽量短、尽量等长级联数量多的时候建议加上拉电阻。因为SCK是时钟信号如果走线过长引入的寄生电容和干扰会导致时钟边沿变缓可能造成数据采样错误。我做过8片级联的板子SCK线上串联了33Ω电阻做阻抗匹配同时每个芯片的VCC旁边加了0.1μF去耦电容实际运行非常稳定。2.3 供电和电平匹配细节74HC595的工作电压是2V到6V和STM32的3.3V系统直接相连完全没问题。如果项目里有5V的外围设备可以考虑给595供电5V输入引脚直接接3.3V的高电平也能被识别为高电平TTL电平兼容但反过来5V输出不能直接接3.3V的MCU引脚。另外595的每个输出引脚最大拉电流是35mA但这是绝对最大值实际使用建议控制在10mA以内驱动LED的时候串联电阻不能省。如果要多路驱动大电流负载考虑加ULN2003之类的达林顿管驱动。3. 可级联驱动函数库实现详解3.1 代码文件架构这个函数库拆成两个文件hc595.h和hc595.c结构很简单清晰方便直接融入任意工程/* hc595.h */ #ifndef __HC595_H #define __HC595_H #include stm32f1xx_hal.h // 以HAL库为例标准外设库用户自行替换 /* 根据实际硬件修改引脚定义 */ #define HC595_SCK_GPIO_PORT GPIOA #define HC595_SCK_PIN GPIO_PIN_5 #define HC595_SCK_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE() #define HC595_RCK_GPIO_PORT GPIOA #define HC595_RCK_PIN GPIO_PIN_6 #define HC595_RCK_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE() #define HC595_DS_GPIO_PORT GPIOA #define HC595_DS_PIN GPIO_PIN_7 #define HC595_DS_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE() /* 常用位操作宏 */ #define HC595_SCK_H() HAL_GPIO_WritePin(HC595_SCK_GPIO_PORT, HC595_SCK_PIN, GPIO_PIN_SET) #define HC595_SCK_L() HAL_GPIO_WritePin(HC595_SCK_GPIO_PORT, HC595_SCK_PIN, GPIO_PIN_RESET) #define HC595_RCK_H() HAL_GPIO_WritePin(HC595_RCK_GPIO_PORT, HC595_RCK_PIN, GPIO_PIN_SET) #define HC595_RCK_L() HAL_GPIO_WritePin(HC595_RCK_GPIO_PORT, HC595_RCK_PIN, GPIO_PIN_RESET) #define HC595_DS_H() HAL_GPIO_WritePin(HC595_DS_GPIO_PORT, HC595_DS_PIN, GPIO_PIN_SET) #define HC595_DS_L() HAL_GPIO_WritePin(HC595_DS_GPIO_PORT, HC595_DS_PIN, GPIO_PIN_RESET) /* 级联数量用户根据硬件修改 */ #define HC595_CHAIN_SIZE 2 /* 对外接口 */ void HC595_Init(void); void HC595_WriteData(uint8_t *dataBuf, uint16_t len); void HC595_WriteOneByte(uint8_t dat); void HC595_Clear(void); #endif这个头文件提供了一个非常关键的宏定义HC595_CHAIN_SIZE级联多少片595就改成多少。后面驱动代码的核心逻辑都和这个宏相关。3.2 底层时序驱动初始化函数主要负责把三个引脚配置为推挽输出模式/* hc595.c */ #include hc595.h void HC595_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; HC595_SCK_CLK_ENABLE(); HC595_RCK_CLK_ENABLE(); HC595_DS_CLK_ENABLE(); GPIO_InitStruct.Pin HC595_SCK_PIN | HC595_RCK_PIN | HC595_DS_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(HC595_SCK_GPIO_PORT, GPIO_InitStruct); /* 初始化默认为低电平状态 */ HC595_SCK_L(); HC595_RCK_L(); HC595_DS_L(); }底层发送一字节的函数要特别注意移位方向。74HC595是在SCK上升沿移入数据标准写法是先发送最高位MSB先行这样第一个到达的数据位就会出现在并行输出的Q7引脚上符合常规的位序习惯static void HC595_ShiftByte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { if (dat 0x80) { HC595_DS_H(); } else { HC595_DS_L(); } /* SCK产生上升沿数据被移入移位寄存器 */ HC595_SCK_H(); /* 极短延时确保时序稳定 */ __NOP(); __NOP(); HC595_SCK_L(); __NOP(); __NOP(); dat 1; } }这段代码里加了两条__NOP()空指令目的是确保DS引脚的电平在SCK上升沿之前稳定。虽然STM32 GPIO速度很快但加一点余量总归更保险尤其在系统时钟较高的时候。如果你还觉得不够稳可以换成一个for(i0;i10;i);的简单延时循环。3.3 级联核心函数实现级联的核心数据发送函数如下void HC595_WriteData(uint8_t *dataBuf, uint16_t len) { uint16_t i; /* 每一个字节对应一片595 */ for (i 0; i len; i) { HC595_ShiftByte(dataBuf[i]); } /* 所有数据移位完成后RCK上升沿一次性锁存输出 */ HC595_RCK_H(); __NOP(); __NOP(); HC595_RCK_L(); }但这里有一个顺序问题需要特别说明如果你的dataBuf[0]对应的是第一片595最靠近MCU的那片那么调用HC595_WriteData时需要反转字节顺序。也就是说你应该把最后一片的数据放在dataBuf[0]的位置。为了方便使用我通常做一个封装函数让调用方可以按自然顺序传入数据void HC595_WriteChain(uint8_t *dataByOrder) { uint8_t buf[HC595_CHAIN_SIZE]; uint8_t i; /* 反转顺序dataByOrder[0]是靠近MCU的第一片 */ for (i 0; i HC595_CHAIN_SIZE; i) { buf[HC595_CHAIN_SIZE - 1 - i] dataByOrder[i]; } HC595_WriteData(buf, HC595_CHAIN_SIZE); }实际使用中我更倾向于直接约定好数据的字节序数组下标0永远对应最后一片595数组最后一个元素对应第一片595。这样在主逻辑里处理数据时按照距离MCU从远到近的顺序组织数据反而更符合直觉——你先想清楚最远处那一片要输出什么再往近处推。3.4 单字节和清空接口在不需要级联或者只控制一片的场景下单独写一字节非常常用void HC595_WriteOneByte(uint8_t dat) { uint8_t buf[1] {dat}; HC595_WriteData(buf, 1); } void HC595_Clear(void) { uint8_t buf[HC595_CHAIN_SIZE]; uint16_t i; for (i 0; i HC595_CHAIN_SIZE; i) { buf[i] 0x00; } HC595_WriteData(buf, HC595_CHAIN_SIZE); }3.5 标准外设库版本适配如果你用的是标准外设库Standard Peripheral Library而非HAL库只需要把宏定义里的GPIO操作换掉即可#define HC595_SCK_H() GPIO_SetBits(HC595_SCK_GPIO_PORT, HC595_SCK_PIN) #define HC595_SCK_L() GPIO_ResetBits(HC595_SCK_GPIO_PORT, HC595_SCK_PIN) #define HC595_RCK_H() GPIO_SetBits(HC595_RCK_GPIO_PORT, HC595_RCK_PIN) #define HC595_RCK_L() GPIO_ResetBits(HC595_RCK_GPIO_PORT, HC595_RCK_PIN) #define HC595_DS_H() GPIO_SetBits(HC595_DS_GPIO_PORT, HC595_DS_PIN) #define HC595_DS_L() GPIO_ResetBits(HC595_DS_GPIO_PORT, HC595_DS_PIN)初始化函数里的时钟使能改成RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE)GPIO配置改成GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP;即可。整体逻辑完全不变。4. 实操案例双595驱动8位数码管4.1 案例需求分析8位数码管动态扫描是74HC595最经典的应用场景之一。8位数码管需要8个段选驱动a~g, dp和8个位选信号一共16个输出正好用2片595级联搞定。我的实际接法是第一片595的输出接数码管的8个段引脚第二片595的输出接8个位引脚通过三极管增强驱动能力。MCU这边只需要3个引脚对比直接用STM32驱动8位数码管需要的16个IO口节省了13个引脚。选择这种接法的原因是段选信号需要同时输出8位数据来点亮某个数码管对应的段位选信号决定哪一位数码管被点亮。利用595的锁存特性可以先把段选数据锁存到输出再切换位选不会有闪烁和串扰。4.2 段码表与显示逻辑共阴数码管的段码表0~9和几个常用字母如下/* 共阴数码管段码表: 0-9, A-F, 全灭 */ static const uint8_t segCode[] { 0x3F, 0x06, 0x5B, 0x4F, 0x66, // 0 1 2 3 4 0x6D, 0x7D, 0x07, 0x7F, 0x6F, // 5 6 7 8 9 0x77, 0x7C, 0x39, 0x5E, 0x79, 0x71, // A b C d E F 0x00 // 全灭 };注意如果你用的是共阳数码管段码需要取反即0x3F变成0xC0。实际应用中这个细节坑了很多新手接上去发现显示的字符不对或者干脆不亮先确认是共阴还是共阳。4.3 动态扫描主循环动态扫描的核心思想是同一时刻只有一位数码管被点亮但利用人眼的视觉暂留效应快速循环点亮每一位看起来就是全部显示。刷新率最低要50Hz最好做到100Hz以上没有闪烁感。void DisplayNumber(uint32_t num) { uint8_t buf[2]; uint8_t i; for (i 0; i 8; i) { uint8_t digit num % 10; /* 段码在第一片595位选在第二片595 */ /* 注意第二片595先发送数据才会出现在更远的芯片上 */ buf[0] 0x01 i; // 位选选中第i位数码管 buf[1] segCode[digit]; // 段码 HC595_WriteData(buf, 2); delay_us(100); // 每位置持续100us8位一个周期约800us刷新率约1.2kHz num / 10; } }这里的buf数组顺序要特别谨慎buf[0]会被最后发送所以它会出现在第二片595上而buf[1]会首先发送出现在第一片595上。通过注释说明清楚了为什么位选在buf[0]、段码在buf[1]这个顺序错一次就什么都显示不出来。我实际测试的时候2片595级联时钟频率按100kHz算发送16位数据只要160μs左右再加上100μs的保持时间一个扫描周期不到300μs8位全扫一轮也只要2.4ms刷新率400Hz左右完全无闪烁。4.4 LED点阵屏扩展思路同样的逻辑稍微扩展一下就能驱动8x8 LED点阵屏。方法是将行选接一片595列选接另一片595扫描方式从位选变成行选每一行根据显示内容发送对应的列数据。如果想把显示内容做大比如16x64点阵继续级联就行唯一的限制是刷新率会下降需要在显示数据量、刷新率和时钟频率之间做平衡。简单算一下64x64点阵需要64行每行64列数据假设每行扫描时间1ms完整刷新一帧要64ms帧率只有15Hz左右会有明显闪烁。这时候就需要用硬件SPI把发送速度提上去。这也是我之前提到的极端场景下改用硬件SPI的典型例子。5. 常见问题与排查技巧实录5.1 常见问题速查表我整理了这几个月来在论坛和群里被问得最多的问题以及我自己调试中遇到的典型异常做成了一个速查表现象可能原因排查方法595完全无输出MR引脚悬空或拉低确认MR接VCC595完全无输出OE引脚高电平确认OE接GND595完全无输出电源没接或者电压不足万用表测VCC输出乱码数据错乱SCK时钟不稳定/信号干扰检查走线、加去耦电容级联时后级输出不对前级Q7引脚接错确认Q7接后级DS级联时数据顺序反了发送顺序理解错了反转数组顺序数码管亮度不均匀位选导通时间差异统一保持时间输出引脚在重启瞬间全高595上电默认输出不确定必要时加下拉电阻或OE控制5.2 一个真实翻车案例仿真正常实物不亮这个案例值得单独说一下。有个朋友用Proteus仿真跑驱动代码逻辑完全没问题仿真波形也完美一到实物板子上就什么都不亮。排查了大半天最后发现是OE引脚没有接。仿真软件里悬空的OE默认低电平芯片正常工作但实物上悬空引脚电平不确定导致输出被禁用。从那以后我搭595电路养成了两个习惯确认MR接高电平、OE接低电平另外在线路上加10kΩ左右的下拉电阻把输出引脚初始状态拉低。特别是驱动LED的时候如果595上电瞬间输出随机高电平会造成LED闪一下甚至过流加下拉电阻可以避免这个问题。5.3 用示波器排查时序问题如果没有示波器用逻辑分析仪也可以。排查时序问题时重点观测三个信号SCK引脚是否产生规则的脉冲序列每个字节8个脉冲。DS引脚的数据是否在SCK上升沿之前稳定建立。RCK锁存信号是否在所有8个SCK脉冲结束后才拉高。如果SCK正常、DS波形看起来不对先检查发送的字节值是否正确。如果SCK没有脉冲检查GPIO配置对不对引脚是否被其他外设复用。我曾经遇到过一个问题STM32的PA5、PA6、PA7这三个引脚默认复用功能是SPI1初始化GPIO时如果忘了设置正确的模式就会导致引脚被SPI外设占用GPIO输出根本不起作用。这种情况用示波器看引脚电平全无变化非常容易怀疑是代码死循环了实际上只是没调用HAL_GPIO_Init把引脚重新配置成普通输出。5.4 提高驱动稳定性的三个细节驱动写稳了以后稳定性才是衡量工程水平的关键。这里分享三个我自己总结的细节其一HC595_WriteData里建议加一个任务级的临界区保护防止发送过程中被中断打断导致数据错位。在裸机开发中可以在函数开头__disable_irq()函数结尾__enable_irq()在RTOS环境中可以用taskENTER_CRITICAL()和taskEXIT_CRITICAL()。其二如果项目里多个外设共用SCK引脚比如同时挂了595和某种SPI存储器注意数据发送时序不能互相干扰。这种情况下更建议用硬件SPI并做好片选管理避免GPIO模拟时序扎堆。其三595的数据传播延迟大约在几十ns级别级联数量增多后前一片的数据传到后一片需要额外时间。如果发现级联很多片之后后级出现偶发数据错误试着降低SCK频率或者在每两个字节之间加一个短延时。6. 一个加速小技巧空间换时间有些场景下比如LED点阵屏需要实时更新显示内容如果希望进一步降低CPU负载可以用一个显示缓冲区配合定时器中断。具体的思路是开辟一块缓冲区uint8_t displayBuf[HC595_CHAIN_SIZE]主循环只修改这个缓冲区的数据。然后启动一个定时器中断在中断服务函数里调用HC595_WriteData(displayBuf, HC595_CHAIN_SIZE)把所有数据刷到595上。这样主循环和显示刷新就解耦了主循环不用关心什么时候刷新只管往缓冲区写数据即可。中断里发送数据时注意关闭其他可能抢占的中断。按照2片595计算在72MHz主频下调用一次HC595_WriteData消耗的时间大约在几十μs量级这个中断开销完全在可接受范围内。如果显示数据比较长或级联数量较多可以适当降低定时器中断频率比如保持在500Hz刷新率需要2000μs刷新一次只要一次刷新耗时小于这个时间就不会影响其他中断响应。我在一个16路跑马灯的项目里就是这么干的主循环跑业务逻辑定时器中断负责刷新595互不干扰写起来非常舒服。7. 关于这个函数库的进一步扩展方向这个驱动函数库目前看起来简单但扩展性其实很强。分享几个我实际用过的扩展方向供参考第一个方向是增加PWM调光或者亮度调节功能。595的OE引脚接定时器PWM输出通过调节PWM占空比实现全局亮度调节。这种方式只需要占用一个定时器通道就能对所有LED输出统一调光硬件不改、代码不加太复杂性价比极高。第二个方向是在595基础上做矩阵键盘扫描。输出用595扩展行驱动输入用74HC165做列采集就能用6根线3根输出3根输入换取大尺寸键盘矩阵比直接扫描省很多IO口。第三个方向是结合DMA和SPI实现无CPU干预的自动刷新。如果使用硬件SPI方式驱动595SPI发送完数据的DMA传输完成中断可以触发RCK锁存脉冲这样整帧数据发送完全不需要CPU干预CPU只需要准备好DMA缓冲区就行。这套方案我在一个需要高刷新率的点阵屏项目里验证过效果非常好。我在实际使用这套函数库的过程中最大的体会是好的驱动函数不在于代码量有多花哨而在于结构清晰、边界明确、改起来不费劲。上面的代码虽然简单但每一处引脚定义、每一个函数接口、每一条时序细节都是从实际项目里踩坑踩出来的经验沉淀。最后再分享一个我自己反复用到的经验无论驱动多简单都要养成先单独测试、再集成到主工程的习惯。单独建一个测试工程把595驱动单独跑起来先点灯、再模拟各种数据变化确认没问题之后再拿到主工程里用。如果直接把驱动代码塞进一个几百行的大工程里调试出了bug很难分清是驱动的问题还是主逻辑的问题而且排查起来极其浪费时间。这个小习惯在很多外设驱动开发里都适用不只是595。本文还有配套的精品资源点击获取
返回列表