简介:移位寄存器是嵌入式系统中扩展数字输入输出最常用的基础器件。74HC165作为典型的并行输入串行输出移位寄存器,通过SH/LD锁存与CLK时钟移位,可将8路并行输入转为串行数据流,仅占用MCU的3个GPIO即可完成采集。相比74HC595实现输出扩展,74HC165专门解决输入资源不足问题,尤其适合按键阵列、拨码开关与传感器状态采集等场景。本文基于STM32 HAL库,完整拆解GPIO模拟时序的驱动实现,包括单字节读取、多片级联、按键扫描实例,并针对CLK空闲电平、SH/LD释放时序、硬件SPI位序等常见坑给出工程验证方法,是一份可直接落地的74HC165驱动参考。
1. 队站在 IO 不够用的板子上:74HC165 驱动到底在解决什么问题
做嵌入式控制面板时,最烦的就是 IO 不够用。几年前我调一块 8 路拨码开关的板子,MCU 引脚已经所剩无几,硬生生挤 8 个 GPIO 读开关状态,布线绕得跟蜘蛛网一样。后来换了思路:用一片 74HC165 把 8 路并行输入转成串行输出,3 根线就搞定了,还顺带支持多片级联,20 路、40 路的输入阵列也能从容应对。这个 74HC165 驱动工程,核心就是给你一套能直接落地的 STM32 驱动代码:GPIO 模拟时序、单字节/多字节读取、按键扫描实例都有,拿过来改改引脚就能跑。尤其适合做按键阵列、拨码开关、传感器状态采集这类输入扩展场景,手里正好缺一份可靠驱动参考的开发者,这份资源能省不少翻数据手册的时间。
2. 硬件与时序:SH/LD、CLK、Q7 这三根线是怎么配合工作的
2.1 74HC165 与 74HC595:一对互补的移位寄存器
很多开发者在接触 74HC165 之前,先玩过 74HC595。74HC595 是串行输入、并行输出,一片可以扩展 8 个输出口,常用于控制 LED 或数码管段选。而 74HC165 正好反过来:并行输入、串行输出,一片可以扩展 8 个输入口,适合批量采集按键、开关和传感器状态。
两者放在一起理解非常直观:输出不够用找 595,输入不够用找 165。从电路设计角度看,74HC165 把 8 路并行的电平信号“折叠”成一根线上的串行数据流,MCU 只需要根数据线和一根时钟线就能展开读取。这里的成本是时间——一次采样要打 8 个时钟脉冲才能拿到一个完整字节,但换来的是宝贵的 GPIO 资源。在按键扫描这类低速场景里,这个时间开销几乎可以忽略不计。
2.2 锁存与移位:一次完整读操作的时序拆解
74HC165 的引脚里,SH/LD(Shift/Load,有的手册写作 SHIFT/LOAD 或 PL)是锁存控制端,CLK 是移位时钟,Q7 是串行数据输出。读取一个字节的标准流程分两步:先锁存,再移位。
第一步,把 SH/LD 拉低。此时芯片会把 8 个并行输入引脚(D7~D0)上的电平“拍照”存进内部寄存器,同时 Q7 会立刻输出 D7 的电平,不需要等任何时钟。这是很多人容易忽略的点:第一个数据位在锁存完成后就已经在 Q7 上了,和第一个时钟边沿没有关系。第二步,把 SH/LD 拉高,然后连续给出 8 个时钟脉冲。在每个时钟上升沿,内部数据右移一位,Q7 依次输出 D6、D5……直到 D0。
从时序图的角度看,SH/LD 拉低后需要保持一小段建立时间(手册上通常是 ns 级),确保并行数据被稳定锁存;拉高后同样需要一点保持时间,才能开始送时钟。用 GPIO 模拟这种时序非常简单,因为主频几十 MHz 的 MCU 本身就比 ns 的时间尺度慢得多,只要保证 SH/LD 低电平持续时间不低于一个 GPIO 翻转周期,就绝对满足手册要求。真正容易翻车的反而是后面要说的时钟顺序问题。
2.3 多片级联的接线与数据顺序
一片 74HC165 只有 8 路输入,接 16 路按键就需要两片级联。级联的物理连接方式很规律:后一片的 Q7 接到前一片的 DS 引脚(串行数据输入,74HC165 专门为级联留的引脚),前一片的 Q7 接 MCU。时钟和锁存信号并联到所有芯片。
读取两片的数据时,MCU 先锁存(所有芯片同时锁存各自的并行输入),然后连续打 16 个时钟脉冲。前 8 个脉冲从第一片(离 MCU 最近的那片)的 Q7 移出数据,后 8 个脉冲移出的是第二片的数据——第二片的数据会通过 DS 引脚“流过”第一片,最终出现在 Q7 上。所以先读到的字节是第一片的,后读到的字节是第二片的。这个顺序在写驱动时把它固化成数组下标就行,物理上哪片靠近 MCU,哪片就排在数组前面,不用额外倒序。
3. 驱动实现拆解:从 GPIO 电平到一整个字节
3.1 工程里驱动文件怎么摆
这个驱动工程的结构不复杂,我把代码分成了驱动层和应用层两层。驱动层是hc165.c和hc165.h,只干一件事:把 GPIO 电平转换成字节流,不关心业务逻辑。应用层由开发者自己写,比如按键扫描、编码器读取之类的模块。接口就两个:一个初始化函数,一个读字节(或读多字节)函数。
这种分层的做法有利于二次开发。比如某个项目里读取的芯片从 1 片变成 3 片,应用层代码不需要改动,驱动层把多字节读取接口做好就行。文件结构大致如下:
project/ ├── Core/ │ ├── Inc/hc165.h │ └── Src/hc165.c │ └── Src/main.c └── ...hc165.h推荐把引脚定义全放在里面,用宏或者枚举管理,换板子时只改头文件,不碰实现代码。
3.2 GPIO 初始化的具体写法
我一般用 HAL 库写这组驱动,标准外设库的写法大同小异。需要注意:3 个引脚里,SH/LD 和 CLK 是输出,Q7 是输入。Q7 的输入模式建议根据硬件电路选择上拉或下拉,如果并行输入端接了按键到 GND,就选上拉;如果悬空,读到的值会乱跳,后面避坑章节会展开讲。
void HC165_Init(void) { GPIO_InitTypeDef gpio_init = {0}; // 使能 GPIOA 和 GPIOB 时钟,这里按实际接线修改 __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); // CLK 和 SH/LD 配置为推挽输出,速度选 LOW 就够 gpio_init.Pin = HC165_CLK_PIN | HC165_SH_LD_PIN; gpio_init.Mode = GPIO_MODE_OUTPUT_PP; gpio_init.Pull = GPIO_NOPULL; gpio_init.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(HC165_CLK_PORT, &gpio_init); // Q7 配置为输入,没有外部上拉时开内部上拉 gpio_init.Pin = HC165_Q7_PIN; gpio_init.Mode = GPIO_MODE_INPUT; gpio_init.Pull = GPIO_PULLUP; HAL_GPIO_Init(HC165_Q7_PORT, &gpio_init); // 初始状态:SH/LD 保持高电平,CLK 保持低电平 HC165_SH_LD_HIGH(); HC165_CLK_LOW(); }参数说明:Speed 选GPIO_SPEED_FREQ_LOW是因为 74HC165 的时钟频率在几百 kHz 就足够了,太高的翻转速度反而可能引入信号完整性问题。Pull 的选择取决于外部电路——如果并行输入默认高电平(按键接地),就选GPIO_PULLUP;默认低电平则选GPIO_PULLDOWN。初始状态把 SH/LD 置高、CLK 置低,是为了让芯片在非读取时段处于“移位就绪但不动作”的状态,避免误触发。
3.3 读单字节核心函数逐行拆
读取单字节是整个驱动的心脏。函数不长,但时序顺序必须理清楚。
uint8_t HC165_Read_Byte(void) { uint8_t data = 0; // 1. 锁存并行输入:拉低 SH/LD,保持一小段时间 HC165_SH_LD_LOW(); delay_us(1); HC165_SH_LD_HIGH(); // 2. 连续打 8 个时钟,逐位移出数据 for (uint8_t i = 0; i < 8; i++) { HC165_CLK_LOW(); // 时钟拉低,准备采样 data = (data << 1) | HAL_GPIO_ReadPin(HC165_Q7_PORT, HC165_Q7_PIN); // 读 Q7 HC165_CLK_HIGH(); // 时钟拉高,上升沿触发移位 } return data; }逻辑说明:锁存完成后,Q7 上已经是最低位还是最高位?74HC165 的移位方向是并行输入的最高位 D7 最先出现在 Q7 上,所以循环里第一次读到的 bit 应放在字节的最高位,于是每读一位就先左移再或上当前值,正好和 D7→D0 的输出顺序对齐。
比较关键的一点是我在循环里有意让CLK_LOW()出现在读数之前。这是因为锁存刚结束时 Q7 输出的第一个位是有效的,不需要时钟就能采;而后面的 7 个位都是在上升沿之后才更新的。用“先拉低、再读、再拉高”的顺序,能保证每次采样都发生在时钟上升沿之前的稳定窗口里,规避了边沿竞争,这在 GPIO 模拟时序里是常见的血泪教训。
参数说明:delay_us(1)不是必须的,但建议保留。SH/LD 低电平宽度手册要求只有几十 ns,GPIO 翻转本身就满足,但加这一微秒能应对不同厂家芯片的差异,也方便调试时用示波器观察锁存窗口。如果嫌这个延时影响扫描频率,可以删掉,实际工程里我删过也没有出过问题,但初次调通不建议省。
3.4 多片读取和按键扫描实例
多片读取不复杂,把上面的单字节逻辑套一层循环就行。这里用数组指针对齐物理级联顺序。
void HC165_Read_Multi(uint8_t *buf, uint8_t chip_count) { // 所有芯片共用 SH/LD,一起锁存 HC165_SH_LD_LOW(); delay_us(1); HC165_SH_LD_HIGH(); for (uint8_t chip = 0; chip < chip_count; chip++) { uint8_t data = 0; for (uint8_t i = 0; i < 8; i++) { HC165_CLK_LOW(); data = (data << 1) | HAL_GPIO_ReadPin(HC165_Q7_PORT, HC165_Q7_PIN); HC165_CLK_HIGH(); } buf[chip] = data; // 先读到的属于靠近 MCU 的那片 } }参数说明:chip_count是级联的芯片数量,buf是输出缓冲区,长度必须不小于chip_count。这个函数内部只打一次锁存脉冲,因为所有芯片的 SH/LD 并联,同时锁存各自并行输入,后续移位是连续完成的,不能每片单独锁存一次,否则后一片的数据会在等待期间丢失。
配套的按键扫描应用层代码也顺手贴出来,假设按键低有效:
void Key_Scan(uint8_t *key_state) { uint8_t buf[1]; HC165_Read_Multi(buf, 1); for (uint8_t i = 0; i < 8; i++) { // 每一位对应一个按键,0 表示按下 if ((buf[0] & (0x01 << i)) == 0) { key_state[i] = 1; } else { key_state[i] = 0; } } }这个扫描函数放在定时器中断里每 10ms 调用一次就行,比在主循环里忙等要干净。注意位序对应关系:buf[0]的 bit0 对应 74HC165 的 D0 引脚,bit7 对应 D7。硬件接线时理清这个映射,软件里就不用做额外翻转。
4. 移植与接入:把这套驱动装进自己的工程
4.1 用 CubeMX 分配引脚时要注意的选项
如果你的工程是用 CubeMX 生成的,引脚分配有几个细节值得花半分钟确认。SH/LD 和 CLK 选 GPIO_Output,Q7 选 GPIO_Input。输出引脚的初始电平在 CubeMX 的 GPIO 设置页里可以直接配,建议把 SH/LD 的初始电平设为 High,CLK 设为 Low,这样生成代码后驱动初始化之前,芯片不会乱移位。
输入引脚 Q7 的上拉/下拉设置要和硬件设计保持一致。很多开发板上的 74HC165 并行输入直接接按键,按键另一端接地,这种电路常态是高电平,Q7 引脚在 CubeMX 里选 Pull-up。如果并行输入接的是传感器开漏输出,可能需要外部上拉,内部上拉不一定扛得住,注意看数据手册的电流参数。
还有一点容易踩:CubeMX 的 GPIO 速度选项。同一个引脚如果被复用成其他外设,比如 Q7 恰好和某个串口的 RX 冲突,CubeMX 会在生成代码时报错。设计电路时尽量给这 3 个引脚选在普通的 GPIO 上,避免和调试口冲突。
4.2 把驱动文件接进已有代码的步骤
把这个驱动接进旧工程,我一般按三步走。第一步,把hc165.c复制进工程的Src目录,hc165.h放进Inc目录,然后在 CubeMX 或 IDE 里把源文件加进编译列表。如果是 Keil,直接在 Project 窗口右键 Add Existing Files;如果是 CMake 工程,在 CMakeLists.txt 里补一行源文件路径。
第二步,改头文件里的引脚宏。hc165.h里会有类似下面的定义:
#define HC165_CLK_PORT GPIOA #define HC165_CLK_PIN GPIO_PIN_0 #define HC165_SH_LD_PORT GPIOA #define HC165_SH_LD_PIN GPIO_PIN_1 #define HC165_Q7_PORT GPIOA #define HC165_Q7_PIN GPIO_PIN_2把端口和引脚号改成你自己工程里的实际接线。如果用了多个 74HC165,Q7 仍然是接到 MCU 的那一根线,从第二片开始的 Q7 都接到前一片的 DS,不占 MCU 引脚。
第三步,在main()初始化阶段调用HC165_Init(),然后就可以在业务代码里调用读取接口了。注意HC165_Init()必须在HAL_Init()和时钟配置之后执行,因为它依赖 GPIO 时钟已经使能。
4.3 一个可跑的扫键换灯示例
为了验证驱动是否正常工作,我习惯做一个最简单的“扫键换灯”实验:8 个按键接 74HC165 并行输入,8 个 LED 直接接 MCU 的另外 8 个 GPIO,按下哪个键就点亮对应的 LED。
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); // 初始化 LED 引脚 HC165_Init(); // 初始化 74HC165 驱动 uint8_t key_buf[1]; while (1) { HC165_Read_Multi(key_buf, 1); // 直接把字节写给 LED 的 GPIO 寄存器 // 这里假设 LED 低电平点亮,按键按下为低电平 uint8_t led_value = ~key_buf[0]; HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin_All, led_value); HAL_Delay(10); } }这个实验能一次性验证三件事:硬件接线是否正确、位序是否对应、锁存时序是否稳定。如果按下第 3 个键点亮的却是第 5 个 LED,多半是位序理解反了,把~key_buf[0]换成字节反转函数即可。如果按键按下时 LED 闪烁不定,说明读到了抖动,需要在应用层加消抖,后面会提到。
5. 避坑手记:这几个 74HC165 时序细节最容易翻车
5.1 现象:CLK 空闲电平没拉对,数据提前移了一位
有次我把 CLK 初始状态设成了高电平,读出来的字节整体像是“错位”了——按下一个键,读到的是相邻位的状态。原因在于 74HC165 的移位发生在时钟上升沿,如果 CLK 空闲时保持高电平,锁存完成后第一个下降沿也会触发移位动作,等于数据在读取循环开始前就偷偷移了一位。
解决方法是把 CLK 的初始状态强制设为低电平,并且保证整个读取循环期间每个时钟脉冲都从低到高完整翻转一次。检查代码里有没有在循环外多打了半个脉冲,用示波器看 CLK 波形,正常应该是一串干净的方波,起始和结束都在低电平。
5.2 现象:SH/LD 释放太晚,第一个位读到的是瞬态
SH/LD 拉低完成锁存后,如果我紧接着不延时就直接拉高并开始读 Q7,有时会读到第一个位的不稳定状态。原因是芯片内部在锁存后需要极短的建立时间让 Q7 输出稳定,手册上这个时间是几十 ns,但工程上留点余量总没错。
解决方式就是驱动里那 1 微秒的延时。如果觉得微秒级延时太粗暴,可以用几个 NOP 指令替代,效果一样。这里不要试图省这几条指令,省出来的时间微乎其微,但省掉的可靠性可能让你排查半天。
5.3 现象:输入脚悬空,读到的值像在随机跳
74HC165 的并行输入引脚如果悬空,读到的值会随环境噪声变化。我第一次搭实验板时没接上拉电阻,结果发现即使没有任何按键按下,扫描出来的字节也时不时冒出一个 1,像在黑匣子里乱跳。
解决方法是区分场景:按键一端接 GND、一端接 74HC165 输入引脚的,必须在输入引脚和 VCC 之间接上拉电阻,或者开启 MCU 内部上拉;反过来按键接 VCC 的,就接下拉电阻。另外也可以在软件里做一次“连续两次读取一致才采信”的确认逻辑,两层保险同时上。
5.4 现象:换成硬件 SPI 后,读出来的字节位序全反了
有人图省事想把 GPIO 模拟改成 SPI 硬件读取,把 CLK 接 SCK、Q7 接 MISO、SH/LD 用普通 GPIO 控制。配置好 SPI 主模式后读回来的字节,经常发现位序和 GPIO 模拟时是相反的。
原因在于 SPI 的 CPOL/CPHA 配置如果是默认的 Mode 0(时钟空闲低、第一个边沿采样),采样时刻落在第一个边沿,而 74HC165 的数据在时钟上升沿之后才更新,时序不匹配。需要把 SPI 配成 Mode 1(时钟空闲低、第二个边沿采样),同时注意 MSB 优先还是 LSB 优先的设置。建议第一次用硬件 SPI 读取时,先读一个已知值比如 0xAA,看输出对不对,再对接业务逻辑。
6. 二次开发思路:从 8 路输入到上百路输入的改造路径
6.1 级联扩展:用数组接口替代单字节接口
驱动里已经提供了HC165_Read_Multi(uint8_t *buf, uint8_t chip_count),二次开发时把按键扫描函数从单字节改成多字节即可。比如用两片 74HC165 接 16 路按键,只需把缓冲区从 1 字节扩到 2 字节,然后逐位判断。级联数量上去之后,建议把按键状态做成位图数组,一个 bit 对应一个按键,既省 RAM 又方便批量处理。
6.2 把 GPIO 模拟替换成硬件 SPI 的接线与配置
如果项目里的主控本身有 SPI 外设,而且你不想让 GPIO 模拟占用 CPU 翻转指令,可以改成硬件 SPI 方式读取。接线如上一条避坑所说:SCK 接 CLK,MISO 接 Q7,SH/LD 用任意一个 GPIO 控制。SPI 配置成主模式、Mode 1(CPOL=0, CPHA=1)、8 位帧、MSB 优先。每次读取先拉低 SH/LD 再拉高完成锁存,然后调用 HAL 的 SPI 接收函数读一个字节,读完后立刻拉高 SH/LD 复位。需要注意的是,跳线距离稍长时 SPI 速度不要拉到最高,几 MHz 足够,信号质量优先。
6.3 按键状态机与消抖:让扫描结果更可靠
裸读 74HC165 的字节直接当按键状态用,机械按键的抖动会带来误触发。我一般把消抖做成一个简单的状态机:连续两次扫描(间隔 10ms)读到的字节一致,才认为按键状态稳定。对每个按键位维护“上次状态”和“当前状态”,只有从松开变为按下时才触发一次按压事件,避免长按被反复触发。
做完这套状态机后,74HC165 的驱动就可以稳定服务于各种输入面板了。从最开始为了省 IO 临时抱佛脚,到后来把这份驱动封装成标准化模块,我每次画板子做输入采集都会复用这一套,唯一要改的就是头文件里的引脚宏。从那以后我每次焊接完输入面板,都会先跑一遍扫键换灯实验确认位序和锁存时序,再往上叠业务逻辑。希望这篇拆解能帮你少踩几个时序坑,顺利把 74HC165 用起来。
本文还有配套的精品资源,点击获取