简介:这份资源面向使用STM32等微控制器进行I/O口扩展的嵌入式开发者,聚焦74HC165并行转串行移位寄存器的驱动实现,帮助解决GPIO资源不足时连接更多外部设备的问题。压缩包共2个文件,以C语言源文件与头文件为主,分别承载驱动逻辑与接口声明,整体约1KB,体量轻便,便于直接加入现有工程。已有738人学习下载,具备一定的参考基础。内容围绕74HC165的引脚功能与工作时序展开,涵盖GPIO初始化、串行时钟与移位使能信号控制、数据串行输入、清零与保持操作以及并行数据读取等关键环节,并说明在矩阵键盘、多位数码管显示、多通道数据采集等场景中的典型用法。读者可据此理解芯片与STM32的接口设计思路,将驱动代码嵌入项目后按实际时序需求调整优化,减少从零编写底层驱动的调试成本。
1. 从一颗并转串芯片说起:74HC165 驱动在 STM32 上到底解决什么问题
做按键矩阵或者拨码开关采集的时候,最直接的做法是把每个开关都接到一个 GPIO 上,代码里轮询或者上中断。可一旦输入点超过十几个,引脚就不够用了,PCB 走线也开始变得难看。这时候 74HC165 这类 8 位并行输入、串行输出的移位寄存器就派上用场了:8 个开关只占 MCU 的 3 根线(时钟、装载、数据),多片还能级联,16 路、24 路输入照样只吃 3 个引脚。这份 74HC165 驱动资源就是围绕 STM32 平台把这套时序封装成可复用代码,适合正在做多路按键采集、拨码开关读取、面板输入扩展的嵌入式开发者。它不复杂,但时序细节和边沿处理很容易翻车,下面按“能干什么、怎么跑、怎么改”拆开讲。
2. 驱动架构与硬件连接:三根线背后的时序设计
2.1 74HC165 的工作机制与引脚角色
74HC165 内部是 8 个 D 触发器加一个移位寄存器。并行输入端 D0~D7 在装载信号(PL,也叫 SH/LD)拉低时被一次性锁存进移位寄存器,随后 PL 拉高,每个时钟上升沿把数据从 Q7 往 QH 串行推出一位。所以整个读取过程分两步:先给一个装载脉冲把当前 8 路电平“拍照”,再打 8 个时钟把这张照片逐位移出来。
对 STM32 来说,需要三根线:
| 信号 | 74HC165 引脚 | STM32 侧 | 方向 | 说明 |
|---|---|---|---|---|
| 装载 | PL (SH/LD) | 任意 GPIO 输出 | MCU→165 | 低电平锁存并行输入 |
| 时钟 | CP (CLK) | 任意 GPIO 输出 | MCU→165 | 上升沿移位 |
| 数据 | QH (Q7) | 任意 GPIO 输入 | 165→MCU | 串行数据输出 |
| 时钟使能 | CE (CLK INH) | 接地 | — | 低电平有效,常接地 |
| 串行输入 | DS | 接地或前级 QH | — | 级联时接前一片 QH |
这里有个容易被忽略的点:CE 必须拉低芯片才响应时钟,很多人焊完发现读出来全是 0 或全 1,最后查出来是 CE 悬空。级联时把第一片的 QH 接到第二片的 DS,时钟和装载信号并联,读的时候连续打 8×N 个时钟,先出来的是最后一片的数据。
2.2 驱动分层:GPIO 抽象与读取接口
驱动代码一般分两层:底层是三个 GPIO 的读写宏,上层是“装载 + 移位 + 拼字节”的读取函数。这样换引脚只改宏,换平台只改底层。下面是一个典型的头文件定义,用宏把引脚操作抽出来,方便移植:
/* hc165.h —— 74HC165 驱动接口 */ #ifndef __HC165_H #define __HC165_H #include "stm32f10x.h" /* 按实际芯片系列替换 */ /* 引脚定义:按实际接线修改这三组宏 */ #define HC165_PL_PORT GPIOA #define HC165_PL_PIN GPIO_Pin_4 #define HC165_CLK_PORT GPIOA #define HC165_CLK_PIN GPIO_Pin_5 #define HC165_DAT_PORT GPIOA #define HC165_DAT_PIN GPIO_Pin_6 /* 片数:级联时改这里,读取长度自动跟着变 */ #define HC165_CHAIN_NUM 1 void HC165_Init(void); void HC165_Read(uint8_t *buf, uint8_t chip_num); #endif宏定义把端口和引脚集中在一处,HC165_CHAIN_NUM控制级联片数。参数说明:buf是输出缓冲区,长度至少chip_num字节;chip_num是实际级联片数,允许运行时传入,方便同一套代码适配不同板子。把片数做成参数而不是写死,是我在多个项目间复用时最省事的一个决定。
2.3 初始化与读取的完整实现
初始化就是把 PL 和 CLK 配成推挽输出、DAT 配成上拉或浮空输入,初始电平 PL 拉高、CLK 拉低。读取函数的核心是“先锁存、再移位”,注意时钟空闲电平必须是低,因为 165 在上升沿移位。
/* hc165.c —— 初始化与读取实现 */ #include "hc165.h" /* 简单延时,保证 165 在低速下也能跟上;主频高时可缩短 */ static void hc165_delay(void) { __NOP(); __NOP(); __NOP(); __NOP(); } void HC165_Init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* PL 与 CLK:推挽输出 */ gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; gpio.GPIO_Pin = HC165_PL_PIN; GPIO_Init(HC165_PL_PORT, &gpio); gpio.GPIO_Pin = HC165_CLK_PIN; GPIO_Init(HC165_CLK_PORT, &gpio); /* DAT:输入,上拉避免悬空误读 */ gpio.GPIO_Mode = GPIO_Mode_IPU; gpio.GPIO_Pin = HC165_DAT_PIN; GPIO_Init(HC165_DAT_PORT, &gpio); /* 空闲态:PL 高、CLK 低 */ GPIO_SetBits(HC165_PL_PORT, HC165_PL_PIN); GPIO_ResetBits(HC165_CLK_PORT, HC165_CLK_PIN); } void HC165_Read(uint8_t *buf, uint8_t chip_num) { uint8_t i, b; /* 1. 装载:PL 拉低锁存并行输入,再拉高 */ GPIO_ResetBits(HC165_PL_PORT, HC165_PL_PIN); hc165_delay(); GPIO_SetBits(HC165_PL_PORT, HC165_PL_PIN); hc165_delay(); /* 2. 逐片逐位读出,先出的是高位 Q7 */ for (b = 0; b < chip_num; b++) { uint8_t val = 0; for (i = 0; i < 8; i++) { val <<= 1; if (GPIO_ReadInputDataBit(HC165_DAT_PORT, HC165_DAT_PIN)) val |= 0x01; /* 上升沿移位:先拉高再拉低 */ GPIO_SetBits(HC165_CLK_PORT, HC165_CLK_PIN); hc165_delay(); GPIO_ResetBits(HC165_CLK_PORT, HC165_CLK_PIN); hc165_delay(); } buf[b] = val; } }逻辑说明:装载脉冲宽度只要满足芯片手册的最小值(纳秒级),hc165_delay里的几个 NOP 在几十 MHz 主频下已经足够,但如果主频拉到上百 MHz 且开了指令流水,建议用示波器确认一下 PL 低电平宽度。移位循环里先读数据再打时钟,是因为第一位数据在装载后就已经出现在 QH 上,第一个上升沿会把它推走,所以必须在第一个时钟之前采样。val <<= 1配合先读后移,拼出来的字节正好是 D7 在最高位、D0 在最低位,和并行端口的位序一致。级联时外层循环按片读,先读出的字节对应离 MCU 最远的那片,如果发现顺序反了,把buf的写入下标倒过来即可。
3. 跑通验证:从裸机工程到示波器抓波形
3.1 最小验证工程搭建
拿到这份驱动后,最快的验证方式不是直接塞进大工程,而是新建一个最小工程:一个 main 循环里反复调用HC165_Read,把结果通过串口打印出来,同时手动把某一路输入接地或接 VCC,看对应位是否翻转。工程配置上,STM32 标准外设库或 HAL 都能用,上面代码用的是标准库风格,HAL 的话把 GPIO 操作换成HAL_GPIO_WritePin/HAL_GPIO_ReadPin即可,逻辑不变。
/* main.c —— 最小验证:读 8 路并打印 */ #include "stm32f10x.h" #include "hc165.h" #include "usart.h" /* 假定已有串口打印封装 */ int main(void) { uint8_t data[HC165_CHAIN_NUM]; USART1_Init(115200); HC165_Init(); while (1) { HC165_Read(data, HC165_CHAIN_NUM); printf("HC165: 0x%02X\r\n", data[0]); for (volatile int i = 0; i < 500000; i++); /* 粗延时,避免刷屏 */ } }这段代码的作用是把读取结果可视化。上电后串口应该稳定输出某个值,用手把 D0 那一路拉到 GND,打印值的最低位应该从 1 变 0(假设该路平时被上拉为高)。如果八路全不变,先查 CE 是否接地、PL 和 CLK 有没有接反;如果值在跳但不对应,多半是位序问题,把并行端 D0~D7 的接线顺序和代码里的移位方向对一遍。
3.2 用示波器确认时序而不是靠猜
嵌入式里最省时间的调试手段就是看波形。把探头挂在 CLK 和 DAT 上,触发设在 PL 的下降沿,应该能看到:PL 一个窄低脉冲,随后 CLK 出现 8 个(或 8×N 个)等间距脉冲,DAT 在每个 CLK 上升沿之前稳定。常见异常是 CLK 空闲电平为高,导致第一个上升沿丢失,读出来整体错一位——这种“玄学错位”靠打印很难定位,波形一眼就看出来。另一个检查点是 PL 脉冲和第一个 CLK 之间的间隔,太短的话 165 还没完成锁存,第一位就是旧数据。
提示:验证阶段把主频降下来、延时加长,先保证功能正确,再逐步提速。上来就满速跑,时序余量不够时问题会间歇出现,排查成本翻倍。
3.3 中断与轮询的取舍
按键采集用轮询还是外部中断,取决于响应要求。74HC165 本身没有中断输出,只能靠 MCU 定时轮询。常见做法是开一个 5~10ms 的定时器中断,在中断里调用HC165_Read,再做软件消抖。注意读取函数里有阻塞延时,放在中断里要保证总耗时远小于中断周期;8 位读取在 72MHz 下也就几微秒,问题不大,但如果级联到 8 片、延时又给得保守,就要算一下时间账,必要时把延时改成基于 DWT 的精确短延时或直接去掉。
4. 避坑与排查:那些让读数全 0 或错位的细节
4.1 读数恒为 0x00 或 0xFF
现象:串口打印永远是 0x00 或 0xFF,手动拨动输入毫无反应。原因通常是三类:CE(时钟禁止)脚悬空或接高,芯片根本不响应时钟;DAT 线接错到了 QH 以外的引脚;或者 PL 一直为低,芯片停在装载态不进入移位。解决:先用万用表确认 CE 对地导通,再确认 DAT 接的是 QH(第 9 脚,不同封装编号略有差异,以手册为准),最后用示波器看 PL 是否有高电平空闲态。这三步走完,九成的“全 0 全 1”都能定位。
4.2 数据整体错一位
现象:读出来的值和实际输入对不上,但存在明显的移位关系,比如实际是 0b00000001,读出来是 0b00000010 或 0b10000000。原因:CLK 空闲电平配错,或者采样和移位的先后顺序写反了。165 是上升沿移位,如果代码里先打时钟再读数据,第一位就被推走了。解决:确认 CLK 初始为低,循环里严格“先读 DAT、再拉高 CLK、再拉低”,位序方向用val <<= 1还是val >>= 1要和硬件接线核对。
4.3 级联时字节顺序颠倒
现象:两片级联,第一片的输入却出现在第二个字节里。原因:级联时数据是从最后一片开始逐位移出的,先读出的字节对应物理上离 MCU 最远的那片。解决:要么在硬件上把靠近 MCU 的那片作为“最后一片”接线,要么在软件里把buf的写入顺序反过来。我一般倾向软件处理,改一行下标比改板子现实。
4.4 高速下偶发误读
现象:低速测试一切正常,主频提上去或者延时去掉后,偶尔读到错误值。原因:PL 脉冲宽度或 CLK 高低电平保持时间逼近芯片极限,加上 GPIO 翻转速度受负载电容影响。解决:保留最小延时,别为了“优化”把hc165_delay整个删掉;必要时把 GPIO 速度档位调低反而更稳,因为过快的边沿会带来振铃。用示波器看边沿是否干净,有振铃就在数据线上加小电容或串阻。
4.5 输入悬空导致读数漂移
现象:没接开关的那几路读数随机跳变。原因:74HC165 的并行输入是高阻 CMOS 输入,悬空时电平不确定。解决:每路输入加上拉或下拉电阻(10kΩ 量级),拨码开关一端接 VCC、另一端接输入并配下拉,或者反过来。这一点在原理图阶段就要定,事后飞线很痛苦。
5. 二次开发:把这套驱动改成你自己的输入采集层
5.1 从“读字节”到“读按键事件”
原始驱动只负责把 8 路电平读成一个字节,真正做产品还得往上包一层:消抖、边沿检测、映射成按键事件。我的习惯是在驱动之上加一个采集任务,维护last和curr两个字节,用异或找出变化的位,再配合计数器做消抖。这样上层业务只关心“哪个键按下/松开”,不碰时序。
/* key_scan.c —— 基于 HC165 的按键消抖与边沿检测 */ #include "hc165.h" #define KEY_NUM 8 static uint8_t key_last = 0; static uint8_t key_cnt[KEY_NUM] = {0}; /* 返回本次新按下的键位图,bit 置 1 表示该键刚按下 */ uint8_t Key_Scan(void) { uint8_t curr, changed, pressed = 0, i; HC165_Read(&curr, 1); changed = curr ^ key_last; /* 找出电平变化的位 */ for (i = 0; i < KEY_NUM; i++) { if (changed & (1 << i)) { if (++key_cnt[i] >= 3) { /* 连续 3 次一致才认账 */ key_cnt[i] = 0; if (curr & (1 << i)) /* 假设高电平为按下 */ pressed |= (1 << i); } } else { key_cnt[i] = 0; } } key_last = curr; return pressed; }参数说明:key_cnt是每个键的稳定计数,阈值 3 对应 3 次扫描周期,若扫描周期 5ms 则消抖时间约 15ms,可按手感调整。pressed只在新按下时置位,适合触发一次性动作;如果要检测长按,再维护一个按住计数即可。这套结构的好处是驱动层和业务层彻底解耦,换芯片(比如换成 74HC595 做输出扩展)时只改HC165_Read这一处。
5.2 移植到 HAL 与不同 STM32 系列
标准库转 HAL 的改动集中在 GPIO 操作:GPIO_SetBits换成HAL_GPIO_WritePin(port, pin, GPIO_PIN_SET),GPIO_ReadInputDataBit换成HAL_GPIO_ReadPin。初始化部分用HAL_GPIO_Init配GPIO_InitTypeDef,字段名从GPIO_Mode_Out_PP变成GPIO_MODE_OUTPUT_PP。跨系列(F1 到 F4、G0)时注意时钟使能宏不同,F4 用__HAL_RCC_GPIOA_CLK_ENABLE()。移植完先跑 3.1 的最小验证,别直接上大工程,否则出问题分不清是驱动还是业务。
5.3 提速与稳定性验证方法
想提高读取频率,先量化再优化:用定时器测HC165_Read单次耗时,再逐步缩短hc165_delay,每改一次跑一轮长时间循环读固定输入,统计错误次数。我的血泪经验是,延时删到零往往还能跑,但换一批芯片或换温度环境就翻车,所以最终版本一定保留一个保守的最小延时,并在注释里写清楚“此处不可删”的原因。验证时把输入固定成 0x55 和 0xAA 交替,跑几万次比对,比手动拨开关靠谱得多。
从那以后我每次做这类移位寄存器驱动,都强制先搭最小验证工程、先用示波器确认时序、再谈集成,绝不把“能打印出数”当成“驱动没问题”。希望帮到你。
本文还有配套的精品资源,点击获取