简介:面向使用STM32扩展I/O口的嵌入式开发者,这份74HC165驱动代码包围绕8位移位寄存器的并行采集与串行读取展开,可用于矩阵键盘、多位数码管显示、多通道数据采集等场景,也适合作为硬件接口时序学习的参考。压缩包共2个文件,包含1个C源文件和1个头文件,整体约1KB,体量轻小,便于直接加入现有工程进行适配。已有739人学习,说明其在相关实践中有一定参考价值。代码侧重GPIO初始化、串行时钟与移位使能时序控制、并行数据装入以及清零和保持等环节,能够帮助读者理清STM32与74HC165之间的接口逻辑,并在此基础上按项目需求调整引脚配置与读取流程,减少从零编写底层驱动的时间,提升多路输入扩展的实现效率。
1. 74HC165 驱动在 STM32 上的真实用途:3 根线换回 24 路输入
某项目重新规划主控板 IO 时,按键、拨码开关和限位信号加起来要占 24 个引脚,而主控上真正能腾出来的空闲口只剩 4 个。把 3 片 74HC165 级联之后,PL、CP、Q7 三根线就把这 24 路并行输入串成了一条移位链,STM32 侧用一个 GPIO 加一个 SPI 就能全部读回。这份 74HC165 驱动资源解决的正是这个场景:在 STM32 上稳定地把并行输入转成串行数据,再拼回可用的字节,顺带处理级联对位、时钟极性和输入抖动。适合手里板子 IO 紧张、又不想上复杂 IO 扩展芯片的人,也适合想搞清移位寄存器时序、以后自己改级联数量的开发者,新手照着接线能跑通,熟手能直接看到参数边界。
2. 时序与接线:PL、CP、Q7 三根线的真值推导和级联顺序
2.1 74HC165 的引脚角色:哪几根线必须由 MCU 控制
74HC165 内部是一个 8 位移位寄存器加一组并行输入锁存,理解它的第一步是把引脚分成「必须由 MCU 驱动」「必须接固定电平」「只用于级联」三类。很多人第一次接线翻车,不是时序写错,而是把 CE 悬空或者把 Q7 和 Q7' 搞混,读回来的数据自然全是乱的。
| 引脚 | 方向 | 作用 | STM32 侧建议接法 |
|---|---|---|---|
| D0~D7 | 输入 | 8 路并行输入 | 接按键、拨码或限位信号,空闲脚加上拉或下拉 |
| PL(SH/LD) | 输入 | 低电平异步加载并行数据 | 普通 GPIO 推挽输出,默认高 |
| CP | 输入 | 移位时钟,上升沿有效 | GPIO 或 SPI SCK |
| CE | 输入 | 时钟使能,低有效 | 直接接 GND,不要悬空 |
| DS | 输入 | 串行输入,用于级联 | 第一片接 GND 或固定电平,其余接上一片 Q7' |
| Q7 | 输出 | 串行输出,当前最高位 | 接 MCU 的 MISO 或普通 GPIO 输入 |
| Q7' | 输出 | Q7 的反相输出 | 只用于级联到下一片 DS |
| VCC / GND | 电源 | 2V~6V 供电 | 与 MCU 同电压最省事,5V 供电要单独评估 |
表格里最容易被忽略的是 CE。它低有效,意思是只有拉低时 CP 上的时钟才会真正让寄存器移位;悬空时电平不确定,表现就是「有时候能读对,有时候整个字节错位」。我一般直接在板上把 CE 焊到 GND,不给它留悬念。DS 也类似,第一片的 DS 如果不接,输入脚浮空,移位进来的低位就是随机值,读 8 位里最低那一位会跳。
Q7 和 Q7' 的区别要单独说。Q7 是移位寄存器最高位的原码输出,Q7' 是它的反相。级联时正确做法是把上一片的 Q7' 接到下一片的 DS,这样数据在片间传递时极性被再反一次,最终拼出来的字节才是正的。用 Q7 去接下一片 DS,链路不会完全没数据,但后面几片读出来会整体取反,看上去很像「错位」,实际是极性错了。
2.2 一次完整的读取时序:PL 脉冲、8 个时钟与位序
一次标准读取只有两个阶段:加载和移位。加载阶段把 D0~D7 一次性锁进寄存器,移位阶段用 8 个时钟把这一字节从 Q7 依次挤出去。顺序搞反或者时钟多打一个,读出来的位序就会整体偏移。
具体步骤是这样的:
- 保证 CP 为低电平,先不要让时钟动。
- 把 PL 拉低,保持至少几十纳秒,这段时间里 D0~D7 的值被异步写入寄存器。
- 把 PL 拉高,加载完成。此刻 Q7 上已经稳定输出 D7。
- 读取 Q7 的当前电平,这是本字节的最高位。
- 给 CP 一个上升沿,寄存器右移一位,Q7 变成 D6。
- 重复第 4、5 步,直到读满 8 位。
关键点在「先读后打时钟」。PL 拉高后 D7 就已经在 Q7 上了,第一个时钟沿到来之前读到的那一位才是最高位;如果先打时钟再读,第一次读到的就是 D6,整个字节左移了一位,最低位补进来的是下一片的数据,现象是「数值总是实际值的两倍附近,且最后一位乱跳」。这个坑我在早期调试时踩过不止一次,逻辑分析仪一挂就能看出来。
位序是 D7 先出、D0 最后出,这一点和 SPI 的 MSB First 正好对得上,所以后面切到硬件 SPI 时不需要做位反转。手工拼字节时按value = (value << 1) | bit循环,读满 8 次得到的正好是一个正常字节。
时间参数上,74HC165 在 4.5V 供电时最大时钟频率在 25MHz 量级,传播延迟十几纳秒,PL 最小脉宽也是几十纳秒。这些数字看着很宽松,但它是器件本身的极限,不是你的板子的极限。排线长、没有地回流、上拉电阻偏大,都会让实际可用频率掉到几兆赫兹。我一般先把时钟压到 1MHz 以内把功能跑通,再往上加,加到出错就退一档。
2.3 级联时的数据先后顺序:三片 165 拼 24 位怎么对位
级联之后的移位链是一条长寄存器,第一片的 Q7' 接第二片的 DS,第二片的 Q7' 接第三片的 DS,只有第三片的 Q7 回到 MCU。打 24 个时钟,最先出来的是第一片的 D7,最后出来的是第三片的 D0。也就是说,第一片的数据在整条流的最前面,第三片在最后面。
拼装的时候要按这个顺序建立映射,不能凭直觉认为「离 MCU 近的那片是低位」。以三片为例,读回来的 24 位按下面这样切:
| 位区间 | 对应芯片 | 对应输入脚 |
|---|---|---|
| bit23 ~ bit16 | 第一片(Q7' 接第二片 DS) | D7 ~ D0 |
| bit15 ~ bit8 | 第二片 | D7 ~ D0 |
| bit7 ~ bit0 | 第三片(Q7 接 MCU) | D7 ~ D0 |
如果实际板子上信号是从第三片开始接的,那映射就要整体反过来写。这件事没有通用答案,必须拿原理图对一遍,或者用一根杜邦线把某一个输入脚拉低,看读回值里哪一位变化,反推出真实映射。我习惯在调试阶段留一个「单脚测试」函数,把每个输入脚逐一点亮一遍,确认映射表再往下写业务逻辑。
还有一个细节:级联数量增加时,移位链变长,最后几片的建立时间会累积。三片、五片一般没问题,十几片还跑几兆时钟就要算一下了。保守做法是时钟频率随片数增加而降低,片数多的时候优先用 GPIO 位操作而不是 SPI,因为 GPIO 版本每一位之间可以插入可控延时,时序更容易压稳。
2.4 接线方案与电平匹配:3.3V MCU 与 5V 器件怎么共存
STM32 的 IO 是 3.3V 域,74HC165 可以 2V~6V 供电,于是很多人直接给它上 5V,觉得 HC 系列输入阈值高、3.3V 驱动 5V 供电的 HC 没问题。发送方向确实大多能凑合,问题出在返回方向:5V 供电时 Q7 的输出高电平接近 5V,直接灌进 STM32 的输入脚,短期可能能用,长期是超出绝对最大额定值的做法,不算稳妥。
常见处理有三种。第一种是整条链路都用 3.3V 供电,最省事,3.3V 下 74HC165 的最高时钟频率会降到十几兆赫兹,对几十千赫兹到几兆赫兹的读取完全够用。第二种是换 74HCT 系列,它的输入阈值是按 TTL 电平设计的,3.3V 驱动更稳,但输出仍然是电源电压,返回方向的问题没解决。第三种是加电平转换,或者至少在 Q7 到 MCU 之间串一个电阻再加钳位,把电流限住。
我自己的习惯是:只要 MCU 是 3.3V,整条 74HC165 链路就统一 3.3V,不给自己留隐患。实在要 5V 供电,就在返回线上做电平转换,别指望「试了没坏」这种玄学结论。另外所有未使用的并行输入脚都要有确定电平,按键类输入加上拉、拨码类输入按下拉,悬空脚在移位时读出来是随机的,会污染整字节的判断。
3. GPIO 位操作驱动:不依赖外设的最小可跑通版本
3.1 为什么先写 GPIO 版本:可观测性和上板调试价值
拿到一份 74HC165 驱动,我一般不建议一上来就用 SPI。SPI 版本效率高,但一旦读错,你面对的是一个黑匣子:时钟是外设发的,采样点是硬件定的,你只能改模式、改分频,然后猜。GPIO 位操作版本虽然慢,但每一位的读写都摆在代码里,配合逻辑分析仪能直接看到「第几个时钟之后数据变了」,排查效率高得多。
GPIO 版本还有一个实际价值:它不占用 SPI 外设。板子上 SPI 可能已经给了屏幕或者 Flash,剩下的引脚又不够,这时候三根普通 GPIO 就能把 24 路输入读回来,是很划算的交换。等 GPIO 版本跑通、映射表确认无误,再切 SPI 做提速,这个顺序比较稳。
代价是 CPU 占用。一次 24 位读取大概要几十条指令,按 10ms 扫描一次算,占用可以忽略;但如果要做到微秒级响应,或者级联片数很多,就得换硬件 SPI 甚至 DMA。这个边界心里要有数。
3.2 三个基础函数:PL 脉冲、单时钟、读 24 位原始值
下面这段是能直接编译进工程的 GPIO 版本,基于 HAL 库,三根线分别接 PA4、PA5、PA6。代码里把「先读后打时钟」的顺序写死了,这是这段逻辑的核心,改的时候不要动这个顺序。
/* 硬件连接: * PL -> PA4(推挽输出,默认高) * CP -> PA5(推挽输出,默认低) * Q7 -> PA6(浮空或上拉输入) * CE -> GND */ #define HC165_PL_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define HC165_PL_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) #define HC165_CP_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET) #define HC165_CP_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET) #define HC165_Q7_READ() HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_6) #define HC165_CHIP_NUM 3u /* 级联片数,改这里就能扩到 4 片、5 片 */ static uint32_t hc165_read_raw(void) { uint32_t value = 0; /* 1. 加载阶段:时钟必须先拉低,再给 PL 一个低脉冲 */ HC165_CP_LOW(); HC165_PL_LOW(); __NOP(); __NOP(); __NOP(); /* 保证 PL 低电平宽度,几十 ns 量级 */ HC165_PL_HIGH(); /* 上升沿完成加载,此刻 Q7 上已经是 D7 */ __NOP(); __NOP(); /* 等 Q7 建立,排线长时这里要加 */ /* 2. 移位阶段:先读当前位,再打一个时钟让 165 移出下一位 */ for (uint32_t i = 0; i < HC165_CHIP_NUM * 8u; i++) { value <<= 1; if (HC165_Q7_READ() != GPIO_PIN_RESET) { value |= 1u; } HC165_CP_HIGH(); __NOP(); __NOP(); /* 时钟高电平宽度 */ HC165_CP_LOW(); __NOP(); /* 两次时钟之间的间隔 */ } return value; }逻辑说明分两段。加载段先把 CP 拉低,是为了避免 PL 拉低期间时钟还在动导致寄存器被提前移位;PL 低脉冲结束后,D7 已经在 Q7 上,所以紧接着的第一件事是读,而不是打时钟。移位段每循环一次做三件事:把已读到的位左移腾出最低位、读当前 Q7、打一个完整时钟。循环次数是片数乘 8,三片就是 24 次。
参数说明集中在三个地方。HC165_CHIP_NUM是级联片数,改它就能扩缩,不需要动循环体。__NOP()的数量决定时钟宽度,72MHz 下一个 NOP 大约十几纳秒,三五个 NOP 对应几十到上百纳秒,对 74HC165 这种几十纳秒级的最小脉宽是够的;如果板上排线长或者加了缓冲器,就多加几个。返回值是原始位流,三片时 bit23~bit16 是第一片,bit15~bit8 是第二片,bit7~bit0 是第三片,和上一章的映射表一致。
3.3 时钟宽度与延时的取值:从器件手册反推而不是拍脑袋
延时不是越多越保险。加太多会让一次读取变慢,10ms 扫描周期里读 24 位如果用了 1ms,剩下的时间就很紧张;加太少则可能踩到建立保持时间的边。合理的做法是拿器件手册里的几个参数反推,再留两三倍余量。
手册里要看的参数有三个:PL 最小脉宽、CP 高电平最小宽度、CP 低电平最小宽度,在 4.5V 供电下都是几十纳秒量级;传播延迟同样在这个量级。把这三个数中的最大值乘 3,就是你在代码里应该保证的最小延时。72MHz 主频下,一个__NOP()十几纳秒,三个 NOP 大约五十纳秒,对大多数板子已经够了。
真正会让这个估算失效的是板级因素,不是芯片。排线超过十几厘米、地线细、没有串联端接,都会让边沿变缓,等效延时变长。所以我的习惯是先按估算值写,跑不通再加,加到能稳定读就停,不追求漂亮的最小值。
3.4 用逻辑分析仪验证:三个必看波形点
代码写完烧进去,读回来的值不对时,不要先改代码,先挂逻辑分析仪。三个点看一遍,大部分问题当场定位。
第一个点是 PL 低脉冲的宽度和位置。它应该出现在每次读取的开头,宽度大于你设定的 NOP 数,而且在它拉低期间 CP 必须是低电平且没有跳变。如果 CP 在 PL 低电平期间还有沿,说明时序写反了或者 CP 初始状态没设对。
第二个点是 PL 拉高到第一个 CP 上升沿之间的间隔。这段间隔里 Q7 应该已经稳定。如果 Q7 在这段时间还在跳,说明输入信号本身在变化,或者上拉太弱、负载太重。
第三个点是 CP 的频率和 Q7 相对 CP 的相位。正常现象是 Q7 在 CP 上升沿之后一小段时间才变化,MCU 在上升沿之前读到的就是稳定值。如果 Q7 和 CP 几乎同时变,说明采样点太靠边,要么降频,要么改成下降沿之后再读。
这三个点看完,再回头对映射表。很多「读出来不对」其实是位序理解错了,不是硬件问题。
4. 切到 SPI 硬件读取:模式、预分频与 DMA 的参数怎么定
4.1 为什么能直接上 SPI:把 CP 交给 SCK、Q7 交给 MISO 的合法性
74HC165 的接口本质就是一个「时钟推动、单线返回」的同步串行口,和 SPI 的 SCK、MISO 完全对得上。PL 不参与移位,用一根普通 GPIO 控制;CE 固定接地;Q7 接 MISO;CP 接 SCK。接好之后,主机发几个字节的时钟,就能读回几片的数据,CPU 不再逐位干预。
合法性的关键在于 74HC165 是「CP 上升沿移位」。SPI 主机每发一个字节会产生 8 个时钟沿,正好对应 8 次移位;主机模式下的HAL_SPI_Receive虽然名字叫接收,但它会主动产生 SCK,这对 74HC165 是必须的。很多人卡在这里,以为只有TransmitReceive才会发时钟,结果用Receive读回全 0 或者全 FF。
另一个前提是 PL 必须在 SPI 传输开始之前完成加载,并且在整个传输期间保持高电平。所以 PL 不能交给 SPI 的 NSS 自动控制,必须手动拉一个脉冲再启动传输。这是 GPIO 和 SPI 混用时唯一需要小心的衔接点。
4.2 SPI 模式与预分频的选定:模式 0 和模式 1 怎么选
SPI 模式由 CPOL 和 CPHA 决定。74HC165 在 CP 上升沿移位输出,所以最自然的组合是 CPOL=0、CPHA=0,也就是模式 0:时钟空闲低,第一个边沿采样。此时主机在上升沿采样,采到的是上升沿之前已经稳定的那一位,165 在上升沿之后才把下一位推出来,逻辑上是自洽的。
但模式 0 有一个隐患:采样点和数据变化点挨得太近。时钟快、排线长、或者 165 的传播延迟偏大时,采样点可能落在数据跳变的窗口里,表现是偶发某一位错。模式 1(CPOL=0、CPHA=1)在第二个边沿也就是下降沿采样,此时数据已经稳定了半个周期,余量更大。
我一般的顺序是先试模式 0,能稳定跑就不动;如果出现「大部分时候对、偶尔错一位」,直接切模式 1,同时把预分频加大一档。这个组合能解决大部分偶发错位。
预分频的取值要看 APB 时钟。以 72MHz 的 SPI1 为例,分频 32 得到约 2.25MHz,分频 16 得到约 4.5MHz,分频 8 得到 9MHz。74HC165 本身能跑更高,但板级因素会先撑不住。我的经验是从分频 32 起步,功能跑通后逐档往上试,出错就退一档,最后停在「稳定档位的上一档再退一档」的位置。
| 参数 | 建议值 | 说明 |
|---|---|---|
| CPOL | 0(低) | PL 加载期间 SCK 保持低电平 |
| CPHA | 0 优先,1 备选 | 偶发错位时切 1 |
| 数据位宽 | 8 位 | 与 165 的字节对齐 |
| 首位 | MSB First | 165 是 D7 先出 |
| 预分频 | 32 起步 | 72MHz 下约 2.25MHz |
| NSS | 软件 | PL 单独用 GPIO 控制 |
4.3 HAL_SPI_Receive 读多片的实现与位序处理
SPI 版本把循环移位交给了硬件,代码短很多。下面这段读三片,读回三个字节,buf[0]是第一片,buf[2]是第三片,和 GPIO 版本的位序一一对应。
/* SPI1: SCK=PA5, MISO=PA6,主机模式,8 位,MSB First * PL : PA4 普通 GPIO 推挽输出,默认高 * CE : GND */ extern SPI_HandleTypeDef hspi1; void hc165_spi_read(uint8_t *buf, uint16_t len) { /* 加载:PL 低脉冲,期间 SCK 必须保持低(模式 0/1 满足) */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); __NOP(); __NOP(); __NOP(); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); __NOP(); __NOP(); /* 等 Q7 建立再发时钟 */ /* 主机模式 Receive 会自己产生 SCK,len 等于级联片数 */ HAL_SPI_Receive(&hspi1, buf, len, 50); }逻辑说明:加载段的写法和 GPIO 版本完全一致,只是把「逐位读」换成了「一次 SPI 传输」。len等于片数,因为每片正好一个字节。超时参数给 50ms,对几兆赫兹的时钟来说非常宽松,给这么大利于在调试时区分「时序问题」和「超时问题」。
参数说明:buf的长度必须不小于len,这是最容易犯的低级错误;len用uint16_t,级联片数超过 255 才需要改类型,实际项目里不会到那个量级。位序上,SPI 的 MSB First 和 165 的 D7 先出正好对齐,读回的字节不需要做位反转,直接用就行。
SPI 初始化代码里几个字段要和表格对齐:
hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; /* 只用了 MISO 也保持 2LINES */ hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; /* CPOL = 0 */ hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; /* CPHA = 0,模式 0 */ hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_32; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); }注意Direction保持两线而不是改成「只接收」,某些库版本对单线模式的处理会影响时钟生成,两线模式最稳。NSS用软件,因为我们根本不用硬件片选,PL 是手动控制的。
4.4 DMA 版本:把 24 位读取做成后台任务
轮询版 SPI 读取会阻塞几十微秒,对大多数应用无所谓,但如果主循环里有严格的实时任务,或者扫描周期要压到 1ms 以内,就值得换成 DMA。DMA 版本的结构是:启动传输,传输完成中断里置一个标志,主循环看到标志再解析缓冲区。
volatile uint8_t hc165_dma_buf[3]; volatile uint8_t hc165_dma_done = 0; void hc165_dma_start(void) { if (hc165_dma_done) { return; /* 上一轮还没处理完,直接跳过 */ } HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); __NOP(); __NOP(); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); __NOP(); __NOP(); HAL_SPI_Receive_DMA(&hspi1, (uint8_t *)hc165_dma_buf, 3); } void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { hc165_dma_done = 1; /* 只置标志,解析留给主循环 */ } }逻辑说明:hc165_dma_done既是完成标志也是忙标志,启动前检查它,避免上一轮 DMA 还没结束就重新拉 PL,那样会把正在传输的数据打断。回调里不做任何解析,只置标志,这是中断里该有的克制。
参数说明:DMA 缓冲区必须是全局或静态的,不能是栈上的局部数组,否则 DMA 传完之前函数已经返回,缓冲区被回收。三片对应三个字节,片数变了两个地方都要改。解析时注意 DMA 写入顺序是buf[0]先到,和 SPI 轮询版一致,映射表不用重写。
4.5 从 GPIO 版迁到 SPI 版的验证顺序
迁移时不要两套代码一起改。我的做法是保留 GPIO 版本作为参照,SPI 版本读出来的三个字节和 GPIO 版本逐字节比对,连续比对几百次全部一致,再删掉 GPIO 版本。比对的时候顺便用一根线把某个输入脚拉低,确认两个版本在同一个位上同时变化,这样映射表也一起验证了。
如果两个版本对不上,先看是不是位序问题,再看是不是模式问题,最后才怀疑硬件。绝大多数情况下,GPIO 版能读对而 SPI 版读错,问题都出在模式、预分频或者 PL 和第一个时钟的间隔上。
5. 避坑与排查:读回全 0、全 1、错位和抖动的 5 个现场
5.1 读回全 0:数据线没回来,或者时钟根本没发
现象:不管输入脚怎么拉,读回值始终是 0x000000。原因通常是三类:Q7 到 MISO 的线没接好或接错脚;CE 被拉高导致时钟不生效;SPI 版本里用了错误的 API,主机没产生时钟。解决顺序是先用万用表量 Q7 在 PL 脉冲后有没有电平变化,再确认 CE 是低,最后用逻辑分析仪看 SCK 上有没有波形。三步里总有一个能定位。
5.2 读回全 1:输入脚悬空,读到的不是你的信号
现象:读回值稳定在 0xFFFFFF 或者高位全 1。原因多半是并行输入脚没有确定电平,CMOS 输入悬空时读出来就是高。拨码开关一端接输入、另一端接 GND 的接法,开关断开时输入就是浮空的。解决是给每个输入脚加一个上拉电阻,或者在软件里对整字节做有效性判断,把全 1 当成无效值丢弃。前者是根治,后者只能兜底。
5.3 数据错位:级联顺序或者位序理解反了
现象:单个输入脚变化时,读回值里变化的位和预期对不上,或者整体偏移一位。原因有两个:一是级联时把第一片当成了低位,实际第一片在最高字节;二是先打时钟后读,导致整体左移一位。解决是拿一根线逐个拉低输入脚,记录每一位的变化位置,重建映射表;同时检查读取循环里读和打时钟的先后顺序。这个排查过程很笨,但一次做完就再也不用猜。
5.4 后几片数据整体取反:Q7 和 Q7' 用混了
现象:第一片数据正常,第二片、第三片读出来和实际输入正好相反。原因是级联时用了 Q7 去接下一片的 DS,而正确接法是 Q7'。反相输出在片间传递时会被再反一次,最终极性才正确。解决是把级联线从 Q7 挪到 Q7',或者接受现状在软件里对后几片取反,但硬件改线更干净,别在代码里留这种历史包袱。
5.5 偶发错一位:模式、频率和排线三者必有一个越界
现象:大部分读取正确,偶尔某一位翻转,重启或者换块板子概率还不一样。原因是采样点太靠近数据跳变沿,或者时钟太快、排线太长导致边沿变缓。解决顺序是先把预分频加大一档,再把模式从 0 切到 1,最后缩短排线或加串联电阻。三招下去还错,就要怀疑电源和地回流,用示波器看 SCK 上有没有过冲。
6. 从能读到读得稳:24 路输入的消抖窗口与一致性校验
6.1 用一致性计数代替逐位消抖
24 路输入里如果有机械按键,原始读数一定会有抖动。逐位写消抖逻辑既啰嗦又难维护,我一般用整帧一致性判断:连续 N 次采样结果完全相同,才认为这一帧有效。这样一套逻辑覆盖所有输入,不用管哪一路是按键、哪一路是拨码。
static uint32_t last_raw = 0; static uint8_t stable_cnt = 0; /* 10ms 调用一次 */ void hc165_scan_10ms(void) { uint32_t raw = hc165_read_raw(); if (raw == last_raw) { if (stable_cnt < 3u) { stable_cnt++; } } else { stable_cnt = 0; /* 有变化就重新计数 */ last_raw = raw; } if (stable_cnt >= 3u) { hc165_apply(last_raw); /* 连续 3 帧一致,交给业务层 */ } }这段的关键参数是采样周期和一致帧数,两者共同决定响应延迟和抗抖能力。下面这张表是不同组合的实际效果,按需选:
| 采样周期 | 一致帧数 | 响应延迟 | 适用场景 |
|---|---|---|---|
| 10ms | 2 | 约 20ms | 拨码开关、限位信号 |
| 10ms | 3 | 约 30ms | 普通按键 |
| 5ms | 3 | 约 15ms | 响应要求高的按键 |
| 20ms | 2 | 约 40ms | 强干扰环境 |
6.2 把无效帧挡在业务层之外
除了抖动,还有一类问题是整帧无效,比如电源波动时读回全 1,或者级联线接触不良导致某一片全 0。这类值如果直接进业务层,会触发误动作。我的习惯是在hc165_apply之前加一道过滤:整帧全 0 或全 1 直接丢弃,和上一帧差异超过 8 位的也丢弃,等下一帧正常了再更新。这道过滤不复杂,但能挡掉大部分现场干扰。
阻抗匹配、上拉阻值、扫描周期这三件事,我在早期项目里吃过亏,后来每次画板子都强制走一遍:先确认每个输入脚有确定电平,再确认 SCK 和 Q7 的走线尽量短且旁边有地,最后把扫描周期和业务响应时间对一遍。这三步做完,74HC165 这条链路基本不会再给你添麻烦。希望帮到你。
本文还有配套的精品资源,点击获取