
简介面向使用STM32扩展并行输入口线的嵌入式开发者这份74HC165驱动程序包围绕8位并行转串行移位寄存器的接口设计与驱动实现展开可用于矩阵键盘、多位数码管显示、多通道数据采集等需要扩展I/O的场景适合具备GPIO时序基础、希望直接接入工程的初中级开发者参考。压缩包共2个文件以1个C源文件和1个H头文件组成体量约1KB分别承载驱动逻辑实现与对外接口声明结构精简便于按现有工程组织方式移植。已有739人学习下载说明其在同类接口驱动资料中具有一定参考面。读者可从中了解SH_CP、LE、SRCLR等控制引脚配合下的移位时序组织方式以及初始化GPIO、串行移入、并行读取和循环采集等环节的代码落地思路并据此结合数据手册核对时序、调整引脚定义快速搭建可验证的74HC165读取流程。1. 32 路拨码开关只占 3 根 IO这份 74HC165 驱动能省下的引脚账项目做到第二版面板上的拨码开关、限位开关和按键一下子涨到 32 路STM32 这边 GPIO 已经排不开了。我不建议为了这些低速输入去换 100 脚的芯片但“每路一个 IO”的思路在这种场景下确实不划算。这份 74HC165 驱动解决的正是这个场景用并行转串行的移位寄存器把 32 路开关量压缩到 PL、CP、Q7 三根线上MCU 侧只按节拍把数据串进来就行。它适合做面板、工控按键板、拨码配置、多路限位采集的人也适合刚学完 GPIO 想找个完整外设练手的开发者。代码量不大但从时序、级联到消抖每个环节都有坑。2. 硬件层先对齐74HC165 的引脚语义和级联扩位动手写代码之前必须先看清硬件。这个项目容易出问题的地方是代码写得漂亮、硬件接错一根线却在软件里找了半天。74HC165 是 8 位并行输入、串行输出的移位寄存器靠一根 PL 装载、一根 CP 移位、一根 Q7 输出CE拉低使能时钟DS是串行输入端专门用来级联。一句话概括它的工作过程PL 拉低时把 D0D7 锁进内部寄存器PL 回高后每来一个 CP 上升沿就往外推一位。2.1 PL、CP、CE、Q7 的时序配合先把这套时序关系理清后面写代码才不会靠猜。关键时序只有三段PL 低电平宽度、PL 上升沿之后到第一个 CP 上升沿的建立时间、以及相邻 CP 之间的周期。74HC165 在 3.3V 下 PL 脉宽要求几十纳秒量级CP 最高频率也在十几兆以上对 STM32 来说都极其宽松——宽松到很多人忘了加延时用 72MHz 的 GPIO 翻转速度去驱动时反而在长排线上翻车。真正要留意的是数据什么时候被采样。移位发生在 CP 的上升沿Q7 上的电平也在上升沿之后更新。所以读取方必须在上升沿之前把 Q7 采走也就是“先读、后打时钟”。这个顺序一旦写反读出来就整体错一位是最典型的症状。引脚侧还有两处必须固定CE有的资料写作 CLK INH必须接 GND悬空等于时钟被随机禁止级联链最前一片的DS不能悬空接 GND 或 VCC 都行但要有确定电平。CMOS 输入悬空时电平会飘读出来的数据自然就不可信。常见的接线方式如下表芯片位置编号是从靠近 MCU 那一片开始往后数信号方向接到 MCU说明PL / SH_LD输出普通推挽 GPIO低电平装载平时保持高CP / CLK输出普通推挽 GPIO 或 SPI SCK上升沿移位Q7 / QH输入上拉输入或 SPI MISO串行数据输出CE / CLK_INH—GND硬件固定不接 MCUDS / SER—前一级 Q7首片接 GND级联入口D0D7输入开关、拨码、限位建议各加 10k 上拉或下拉2.2 级联扩位8 位怎么变成 32 位级联的物理连接很简单前一片的 Q7 接后一片的 DS四片串成一条链只有最后一片的 Q7 回到 MCU。麻烦的是顺序。链上时钟打一拍所有芯片同时移位所以最先被推出来的是离 MCU 最近那一片的 D7接着是它的 D6……D0然后才是第二片的 D7D0依此类推。这里有个反直觉的点离 MCU 最近的那一片通常是链路上的“末级”。数据从远端一片一片往 MCU 方向挤时钟数等于总位数多打一拍少打一拍都会让整条链的数据整体偏移。我习惯在板子上用丝印把片号标清楚代码里的数组下标直接对应片号避免后期维护时靠回忆。读取的位数固定是片数 × 8。四片就是 32 个时钟。如果有人为了“保险”多打几个时钟读到的数据会整体左移若干位现象是按键全部错位看起来像映射表写错了实际是时钟数不对。反过来少打时钟则会丢掉最后一两位最高通道永远读不到这种 bug 极难通过观察发现只能靠数时钟。3. 驱动骨架位带和 SPI 两条实现路径驱动层其实就是在做一件事按正确的顺序产生 PL 脉冲和一串 CP 时钟同时把 Q7 上的电平攒成字节。实现方式有两种一种是用 GPIO 直接翻转电平好处是简单直观、不占用外设另一种是把 CP 交给 SPI 硬件好处是时序精准、可以配 DMA。两种实现放在同一个工程里并不冲突接口保持一致就能互换。3.1 位带版本三根 IO 的最小实现先给一个不依赖任何外设的版本。这个版本可以直接用寄存器操作 GPIO也可以用 HAL 封装思路一样。代码里把引脚操作抽成宏换板子时只需要改宏定义。/* hc165.h —— 对外只暴露两个函数调用方不关心底层怎么移位 */ #ifndef __HC165_H #define __HC165_H__ #include stdint.h #define HC165_CHAIN_NUM 4u /* 级联片数4 片 32 位 */ #define HC165_BITS (HC165_CHAIN_NUM * 8u) void HC165_Init(void); void HC165_ReadAll(uint8_t *buf); /* buf 长度 HC165_CHAIN_NUM */ #endif/* hc165.c —— 位带版本只占用 3 根 IO */ #include hc165.h #include board_gpio.h /* 工程里统一的引脚配置层 */ #define PL_LOW() (HC165_PL_PORT-BRR HC165_PL_PIN) #define PL_HIGH() (HC165_PL_PORT-BSRR HC165_PL_PIN) #define CP_LOW() (HC165_CP_PORT-BRR HC165_CP_PIN) #define CP_HIGH() (HC165_CP_PORT-BSRR HC165_CP_PIN) #define Q7_READ() ((HC165_Q7_PORT-IDR HC165_Q7_PIN) ? 1u : 0u) /* 72MHz 下约 1us对应 74HC165 五百 kHz 左右的时钟留足余量 */ static inline void hc165_delay(void) { for (volatile uint8_t i 0; i 12u; i) { __NOP(); } } void HC165_Init(void) { board_gpio_config_out(HC165_PL_PORT, HC165_PL_PIN); /* 推挽输出 */ board_gpio_config_out(HC165_CP_PORT, HC165_CP_PIN); board_gpio_config_in_pullup(HC165_Q7_PORT, HC165_Q7_PIN); PL_HIGH(); /* 平时不装载避免输入端噪声进去 */ CP_LOW(); /* 时钟空闲低电平 */ hc165_delay(); } void HC165_ReadAll(uint8_t *buf) { uint8_t b; /* 第一步装载。PL 低电平期间 D0~D7 进入内部寄存器 */ PL_LOW(); hc165_delay(); PL_HIGH(); hc165_delay(); /* 装载后的建立时间别省这一句 */ /* 第二步先读后打时钟共 片数*8 个上升沿 */ for (uint8_t chip 0; chip HC165_CHAIN_NUM; chip) { b 0; for (uint8_t bit 0; bit 8u; bit) { b 1; /* MSB first第一个读到的就是 D7 */ if (Q7_READ()) { b | 0x01u; } CP_HIGH(); /* 上升沿内部寄存器左移一位 */ hc165_delay(); CP_LOW(); hc165_delay(); } buf[chip] b; } }这段代码有三个参数会直接决定成败。HC165_CHAIN_NUM必须和实际级联片数严格一致多一片少一片都会整体错位。hc165_delay里的循环次数和主频相关72MHz 下 12 次空转大约 1us如果工程跑在 168MHz 或用了低功耗降频这个数要重新标定——常见的做法是先用逻辑分析仪量一次 CP 周期倒推出循环次数而不是凭经验填数字。b 1的位置决定了位序写成b 1并配b | 0x80也能出结果只是位序整体反过来后期映射表要跟着改。一个细节这里每个 bit 都调了一次hc165_delay实际读一片 8 位加四片一共 32 次耗时大约 70us。对 10ms 一次的按键扫描来说是完全可以接受的开销但如果一条链上串了十几片就该考虑换 SPI 或者降低扫描频率了。3.2 SPI 外设版本把移位交给硬件当级联片数变多或者系统里还有别的实时任务不想被 70us 的阻塞拖住就该让 SPI 干活了。接法是把 CP 接到 SCK、Q7 接到 MISOPL 仍然用普通 GPIO 控制因为它是装载信号不是标准的片选行为。发送任意数据内容无所谓接收到的就是移位链吐出来的数据。这里有个非常容易出错的点SPI 模式必须选模式 1CPOL0CPHA1。原因前面提过74HC165 在 CP 上升沿更新 Q7主机的模式 0 也是在上升沿采样采样点和从机的更新点是同一个边沿谁先谁后全看走线延迟这就是典型的玄学现象——换了批芯片结果不一样。/* hc165_spi.c —— 用硬件 SPI 读级联链 */ #include hc165.h #include spi_bus.h static uint8_t s_tx[HC165_CHAIN_NUM]; /* 哑数据内容无意义全 0 即可 */ void HC165_SPI_Init(void) { /* 模式 1从机上升沿更新主机下降沿采样天然错开半个周期 */ spi_bus_init(SPI_BUS_1, SPI_MODE_1, SPI_1_5MHZ); board_gpio_config_out(HC165_PL_PORT, HC165_PL_PIN); PL_HIGH(); } void HC165_SPI_ReadAll(uint8_t *buf) { PL_LOW(); hc165_delay(); PL_HIGH(); hc165_delay(); /* 装载完成后才能给时钟否则锁进去的是旧数据 */ /* 全双工传输发出去的是哑数据收回来的是移位链数据 */ spi_bus_transfer(SPI_BUS_1, s_tx, buf, HC165_CHAIN_NUM); }spi_bus_transfer的第三个参数是字节数等于级联片数。SPI 时钟选 1.5MHz 是基于排线长度的经验值板内短线跑 8MHz 也没问题但只要 PL、CP 和 Q7 走过 20cm 以上的排线时钟再快就容易在 CP 边沿上看到振铃采到的数据会偶尔跳一位。这种错误不会一直出现只在特定温度或特定按键组合下冒出来排查成本极高。如果系统对 CPU 占用敏感还可以把这个传输挂到 DMA 上定时器溢出触发一次传输完成中断里再处理按键状态机。这套组合在 32 位链、10ms 周期的场景下 CPU 占用几乎可以忽略。3.3 三种节拍的取舍同一份驱动有三种跑法选哪种取决于链长和系统负载我给一张对照表方便对着项目挑方式典型耗时32 位CPU 占用适用场景主要风险GPIO 直接翻转约 70100us全程阻塞片数少、无实时任务延时参数要按主频标定SPI 阻塞传输约 25us短时阻塞片数中等、周期固定模式选错导致整体错位SPI DMA几乎为 0异步长链、多任务系统缓冲区被并发改写我一般的做法是先上 GPIO 直接翻转把功能跑通确认硬件和位序都对再换成 SPI 版本做性能优化。反过来的顺序会让你在硬件还没验证的情况下同时怀疑两个变量出了问题根本分不清是接错线还是模式选错。4. 上层封装位序还原和按键状态机拿到字节数组只是拿到了原始位流离“哪个按键按下了”还差两步把分散在多个字节里的位拼成一个 32 位整数再把这个整数接到消抖状态机上。这两步看起来琐碎但上层业务代码全依赖它们接口设计得干净一点后面加通道、改映射都不用动业务层。4.1 位序还原与通道映射表拼接的核心是移位。buf[0]是离 MCU 最近那片的 D7D0buf[1]是下一片所以逻辑通道号和物理位的关系是通道 8 * 片号 位号。这段代码短但值得单独封装因为一旦硬件改版比如某次布线把首片接到了链尾只需要改这里。/* 把原始字节数组打包成 32 位位图bit0 对应 0 号通道 */ static uint32_t hc165_pack(const uint8_t *raw) { uint32_t v 0; for (uint8_t i 0; i HC165_CHAIN_NUM; i) { v | (uint32_t)raw[i] (8u * i); } return v; } /* 通道映射有些板子把 D0 布到了面板最右边这里统一成从左到右 */ static uint32_t hc165_remap(uint32_t raw) { uint32_t out 0; for (uint8_t ch 0; ch HC165_BITS; ch) { if (raw (1uL ch)) { out | (1uL s_channel_map[ch]); /* s_channel_map 由板级配置给出 */ } } return out; }s_channel_map是一张 32 字节的静态数组内容由硬件布线决定。这张表的价值在于把“物理位置”和“逻辑编号”解耦面板装配顺序变了、维修换了排线改表就行业务层拿到的永远是稳定的逻辑编号。我见过不少项目把映射写死在业务里后面每加一块板子就要改一遍判断逻辑维护起来非常痛苦。拼接时还要注意有效电平。拨码开关通常一端接地、一端接 D 输入并配上拉所以开关闭合读到 0断开读到 1。这种反相逻辑最好在驱动层就取反掉让上层拿到“1 表示有效”的一致语义否则业务代码里到处是if (!(v mask))读起来累还容易写错。4.2 扫描节拍与消抖状态机机械触点的抖动时间通常在 520ms所以扫描周期定在 10ms 比较合适连续三次一致才认账等效消抖窗口 30ms。这个值不是随便定的窗口太短消不干净太长则快速连按会被吃掉。轻触按键按一次大概 80150ms30ms 窗口不会影响手感。状态机用每个通道一个计数器实现内存占用小逻辑也好读/* key_scan.c —— 10ms 定时器里调用输入是已还原的位图 */ #include key_scan.h static uint8_t s_cnt[HC165_BITS]; /* 每个通道的连续一致计数 */ static uint32_t s_stable; /* 已确认的稳定状态1 按下 */ static uint32_t s_edge; /* 本次扫描新产生的按下沿 */ void Key_Scan10ms(uint32_t raw) { uint32_t now ~raw; /* 硬件低有效取反成 1按下 */ s_edge 0; for (uint8_t i 0; i HC165_BITS; i) { uint32_t m 1uL i; if ((now m) (s_stable m)) { s_cnt[i] 0; /* 与稳定值一致说明刚才只是抖动 */ } else if (s_cnt[i] 3u) { /* 连续 3 次30ms不一致才认账 */ s_cnt[i] 0; s_stable ^ m; if (s_stable m) { s_edge | m; /* 只在按下沿置位长按不重复触发 */ } } } } uint32_t Key_GetPressEdge(void) { return s_edge; }s_cnt用uint8_t就够因为一旦超过 3 就清零不会溢出。s_stable用位图而不是数组取反、与或都很方便32 位以内一个寄存器就装下了如果链更长超过 32 位换成uint64_t或者按片分组处理。s_edge只保存按下沿是为了方便业务层做“单击触发一次”的逻辑长按和连击如果需要可以在这个状态机上再加一层计时。调用位置很关键一定要放在定时器中断或 RTOS 的固定周期任务里不要塞进主循环。主循环里跑扫描周期会随其他任务波动消抖窗口的实际时长就飘了。我习惯用一个 10ms 的软件定时器统一调度扫描和驱动读取放在同一个节拍里保证“读一次、判一次”严格配对。5. 避坑与排查几个让我返工的现场问题这套驱动原理不复杂但真正落到板子上出问题的往往是电气细节和环境因素。下面这几条都是我在实际项目里遇到过的按“现象—原因—解决”记下来遇到类似症状可以先照着对一遍。5.1 电气层上拉、串阻和电平匹配现象整条链读出来全是 0xFF拨码怎么拨都没反应。原因往往不是代码而是CE悬空或者DS没接。CMOS 输入悬空时电平处于不定态时钟被随机禁止移位寄存器根本不工作。解决办法是把CE直接焊到 GND首片DS接一个确定的电平GND 或 VCC 都行Q7 上加 10k 上拉。这类问题用万用表量一下引脚电压就能定位比在代码里加打印快得多。现象按键偶尔误触发手靠近面板就跳。排线超过 20cm 且没有串阻时CP 的快速边沿会耦合到相邻的 Q7 上。解决方式有三个层次CP 线上串 100Ω 电阻抑制边沿速率降低时钟频率到 1MHz 以下采样点尽量远离 CP 的跳变沿。三个一起上效果最稳我现在的板子基本都带串阻成本几分钱省下的调试时间是以天计的。现象3.3V 的 MCU 读 5V 供电的移位链数据时好时坏。这是电平不匹配Q7 输出接近 5V长期这样用会损伤输入脚。正确做法是整条链统一 3.3V 供电如果确实需要 5V 侧输入选 TTL 电平兼容的型号它的输入阈值按 TTL 标准设计3.3V 输出能直接被识别。这一条属于硬件设计阶段就该定下来的事后期改板代价很大。5.2 软件层位序、锁存和中断抢占现象SPI 方式读出来整体左移一位第一个字节的最低位永远是 0。这是 CPHA 选错模式 0 在上升沿采样正好撞上从机更新的同一个边沿。改用模式 1 后立刻正常。判断方法很简单把某个 D 输入固定接高、其余接地看读到的 1 落在第几位一位偏差马上就能看出来。现象低功耗唤醒后的第一帧数据总是错的。休眠时 GPIO 被重新配置成模拟输入或掉电状态唤醒后 PL 和 CP 的电平不确定第一次读之前没有重新装载读到的是上次残留的移位寄存器内容。解决是在唤醒回调里重跑一遍HC165_Init()或者在每次ReadAll开头无条件做一次 PL 低-高代价只有 1us但能彻底消除这个隐患。现象加了 RTOS 之后按键偶尔丢一次。扫描任务优先级低于串口或通信任务时10ms 的节拍会被拉长到 30ms 以上消抖计数器直接跨过阈值一次抖动被当成多次按下。解决是把扫描放在定时器中断里或者在任务里用绝对时间戳判断“距离上次扫描是否已满 10ms”而不是简单osDelay(10)。后者在负载高时周期必然漂移属于典型的血泪经验。6. 二次开发把驱动接成你自己的输入子系统如果你打算在这个驱动上继续做东西我的建议是别急着改HC165_ReadAll本身而是在它上面加一层薄封装。驱动层只负责“给出 32 位原始位图”这一件事语义解释、消抖、事件分发都放到上层这样换硬件、改通道数、加新按键都不会牵动底层。具体做法是先定义一组统一的输入事件结构把移位链、普通 GPIO 按键、甚至矩阵键盘都塞进同一个接口/* input_if.h —— 所有输入源实现同一套接口业务层不关心来源 */ typedef struct { uint32_t (*read_raw)(void); /* 返回原始位图1 有效 */ uint8_t (*get_id)(uint8_t ch); /* 逻辑通道 - 业务 ID */ } input_source_t; void Input_Register(const input_source_t *src); uint32_t Input_GetPressEdge(uint8_t src_id);实现read_raw时把HC165_ReadAll加hc165_pack和hc165_remap串起来就行几行代码。业务层只面对Input_GetPressEdge将来换成别的采集方式改一个注册项就够了。这套抽象在项目只有一路输入时看着多余但面板从 32 路扩到 64 路、或者要同时接两种输入源的时候收益非常明显。改造时最容易踩的坑是缓冲区并发。如果你用 DMA 版本spi_bus_transfer的接收缓冲区不能在传输未完成时被上层读取否则会读到半新半旧的数据。稳妥的写法是双缓冲DMA 写 A 区完成后中断里把 A 区拷到只读快照区上层永远读快照。拷贝 4 个字节的开销可以忽略但避免了一整类偶发问题。验证方法我一般分三步走。第一步把某个 D 输入固定接 GND其余接 VCC读出来的位图应该只有一个 0逐位轮换一遍确认通道映射表没写反。第二步用逻辑分析仪抓 PL 和 CP数一数一次读取产生多少个时钟沿必须严格等于片数 × 8。第三步长时间运行看是否有跳变把读到的原始值用串口按固定格式打印跑一晚上出现不规律的位翻转基本就是电气问题去查串阻和供电。对于这种靠边沿和时序吃饭的驱动我有一条自己坚持的习惯任何一次硬件改版哪怕只是换了排线长度都强制走一遍上面这三步验证不靠“上次好的这次应该也行”的判断。移位寄存器这类器件平时安安静静一旦出问题又极其隐蔽提前花二十分钟验证比事后花两天排查划算得多。希望帮到你。本文还有配套的精品资源点击获取