简介:这份资源面向使用STM32等微控制器进行I/O口扩展的嵌入式开发者,聚焦74HC165并行转串行移位寄存器的驱动实现,帮助解决GPIO资源不足时连接更多外部设备的问题。压缩包共2个文件,包含1个C源文件与1个头文件,分别承载驱动逻辑与接口声明,整体约1KB,体量轻便,便于直接加入现有工程。已有738人学习下载,具备一定的参考基础。内容围绕GPIO初始化、串行时钟与移位使能时序控制、数据串行输入、清零与保持操作以及并行数据读取等环节展开,可应用于矩阵键盘、多位数码管显示、多通道数据采集等场景。读者可据此快速理解74HC165与STM32的接口设计思路,获得可复用的驱动代码框架,并在此基础上按实际时序需求修改优化,提升系统扩展能力。
1. 74HC165驱动:一块并转串芯片怎么把16路按键塞进3根IO
手上有个板子要读16个拨码开关,MCU的IO已经排得满满当当,再占16个脚基本没戏。这种场景下74HC165就是那个“后悔药”——8位并行输入转串行输出,两片级联就能把16路状态压到3根线上(时钟、锁存、数据)。这份名为“74HC165驱动.rar”的资源,核心就是围绕STM32把这块芯片的时序跑通,包含驱动代码和配套的读写逻辑。它适合正在做按键矩阵、拨码开关采集、IO扩展的嵌入式从业者,尤其是被IO数量卡住、又不想上I2C扩展芯片的人。下面按“芯片怎么工作→代码怎么落地→坑在哪”的顺序拆开讲,参数和时序都能直接抄。
2. 74HC165的时序骨架:先锁存再移位,别把顺序搞反
74HC165本质是一个8位并行输入的移位寄存器,工作分两步:锁存(Load)和移位(Clock)。理解这两步的先后关系,是驱动能不能跑通的分水岭。很多人第一次写驱动翻车,不是代码语法错,而是把“先给时钟再锁存”当成了默认动作,结果读回来的永远是上一次的旧数据。
2.1 三个控制脚各自管什么
芯片上跟MCU打交道的信号线主要有四根,驱动里必须一一对应:
| 引脚 | 方向 | 作用 | 常见接法 |
|---|---|---|---|
| PL(SH/LD) | MCU→芯片 | 低电平锁存并行数据,高电平进入移位 | GPIO推挽输出,默认拉高 |
| CP(CLK) | MCU→芯片 | 移位时钟,上升沿移出一位 | GPIO推挽输出,默认低 |
| Q7(QH) | 芯片→MCU | 串行数据输出 | GPIO输入,上拉或浮空 |
| Q7'(QH') | 芯片→下级 | 级联输出,接下一片Q7 | 仅级联时用 |
PL是低有效这一点必须记牢:拉低的那一瞬间,8个并行输入被“拍照”进寄存器;拉高之后,每来一个时钟上升沿,数据就从Q7往外吐一位。所以正确的动作序列是“PL拉低→PL拉高→再打时钟”,顺序反了读到的就是移位寄存器里的残留值。我一般会在初始化时把PL置高、CP置低,让芯片处于待命状态,避免上电瞬间误锁存。
2.2 级联时数据是怎么排队的
两片74HC165级联,第一片的Q7'接第二片的Q7输入(注意是串行输入脚,不是并行脚),MCU只读第一片的Q7。移位时,第一片的8位先出去,紧接着第二片的8位被“推”进第一片继续往外吐,所以读16位要打16个时钟。这里有个容易忽略的点:级联后第一个读出的位,是最后一片的最高位还是最低位,取决于你接线时Q7'接的是哪一端。常见做法是把靠近MCU的那片当“高位片”,读回来的16位里高8位来自它,低8位来自远端片,拼装时按这个约定移位就行。
/* 读取两片级联的16位并行输入,返回拼装后的值 */ uint16_t hc165_read16(void) { uint16_t data = 0; uint8_t i; /* 1. 锁存:PL拉低再拉高,把16路并行状态拍进寄存器 */ HAL_GPIO_WritePin(HC165_PL_PORT, HC165_PL_PIN, GPIO_PIN_RESET); __NOP(); /* 给芯片一点建立时间,几纳秒即可,NOP足够 */ HAL_GPIO_WritePin(HC165_PL_PORT, HC165_PL_PIN, GPIO_PIN_SET); /* 2. 移位:打16个时钟,每个上升沿读一位 */ for (i = 0; i < 16; i++) { data <<= 1; /* 先左移,给新来的位腾位置 */ HAL_GPIO_WritePin(HC165_CP_PORT, HC165_CP_PIN, GPIO_PIN_SET); __NOP(); if (HAL_GPIO_ReadPin(HC165_Q7_PORT, HC165_Q7_PIN) == GPIO_PIN_SET) { data |= 0x01; } HAL_GPIO_WritePin(HC165_CP_PORT, HC165_CP_PIN, GPIO_PIN_RESET); __NOP(); } return data; }这段代码的逻辑说明:先执行锁存动作,PL低脉冲把并行口状态装进移位寄存器;然后循环16次,每次先把CP拉高产生上升沿(芯片在上升沿移出下一位),紧接着读Q7,再把CP拉低准备下一次。data <<= 1放在读之前,保证第一次读到的位最终落在最高位,这样拼出来的16位和物理接线顺序一致。参数上,__NOP()是给GPIO和芯片留的建立/保持余量,STM32跑几十兆主频时一个NOP约几十纳秒,对74HC165这种百纳秒级器件够用;如果主频很高或走线很长,可以换成几个NOP或短暂延时。注意读Q7要在CP拉高之后、拉低之前完成,这个窗口是数据有效的区间。
3. STM32上的驱动落地:GPIO配置、读函数与消抖
时序清楚了,落到STM32上就是GPIO初始化和读函数两件事。这份驱动资源的价值在于把“能跑”的细节固定下来,而不是让你每次重新推一遍时序。下面按初始化、读取、消抖三个层次展开,参数都能直接改。
3.1 GPIO初始化:输出推挽、输入上拉的取舍
PL和CP是MCU输出,配推挽、低速即可,74HC165对边沿速度没要求,低速反而减少振铃。Q7是输入,这里有个选择:配上拉还是浮空。如果板上Q7外部已经有上拉电阻,MCU内部上拉可以关掉;如果没有,建议开内部上拉,防止芯片未上电或断线时读到随机电平。我一般默认开上拉,多花的那点功耗在按键采集场景里可以忽略。
/* 74HC165 GPIO初始化,STM32 HAL库写法 */ void hc165_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); /* 按实际端口改时钟 */ /* PL:锁存控制,推挽输出,默认高电平(不锁存) */ gpio.Pin = HC165_PL_PIN; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(HC165_PL_PORT, &gpio); HAL_GPIO_WritePin(HC165_PL_PORT, HC165_PL_PIN, GPIO_PIN_SET); /* CP:移位时钟,推挽输出,默认低电平 */ gpio.Pin = HC165_CP_PIN; HAL_GPIO_Init(HC165_CP_PORT, &gpio); HAL_GPIO_WritePin(HC165_CP_PORT, HC165_CP_PIN, GPIO_PIN_RESET); /* Q7:串行数据输入,上拉,防止悬空误读 */ gpio.Pin = HC165_Q7_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(HC165_Q7_PORT, &gpio); }逻辑说明:三个脚分三组配置,PL和CP同为输出但默认电平相反——PL默认高(不锁存),CP默认低(无时钟沿),这个初始状态让芯片上电后处于“等锁存”的稳定态。Q7开内部上拉是关键细节,很多板子为了省成本不焊外部上拉,靠MCU内部上拉兜底,读到的空闲电平才是确定的高。参数上,GPIO_SPEED_FREQ_LOW对74HC165完全够,别设成HIGH,高速边沿在长排线上容易引入串扰。
3.2 读函数与位映射:物理开关怎么对上逻辑位
读回来的16位是“移位顺序”,不等于“物理引脚顺序”。假设第一片接开关1~8、第二片接9~16,且第一片Q7'接第二片输入,那么读出的bit15对应开关1,bit0对应开关16(具体取决于接线端)。这一步必须在代码里用宏或映射表固定下来,否则后面判断按键时全是错位。常见做法是定义一个映射数组,把逻辑位号映射到物理开关编号,读完后统一转换。
/* 把16位原始数据映射成开关状态,bit=1表示开关闭合(按接线极性调整) */ #define SW_COUNT 16 static const uint8_t bit_map[SW_COUNT] = { /* 逻辑开关0~15 对应的原始数据位号,按实际接线填 */ 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 }; void hc165_get_switches(uint8_t *sw) { uint16_t raw = hc165_read16(); uint8_t i; for (i = 0; i < SW_COUNT; i++) { sw[i] = (raw >> bit_map[i]) & 0x01; } }逻辑说明:bit_map把“第i个开关”对应到原始数据的哪一位,接线一变只改这张表,读函数不用动。(raw >> bit_map[i]) & 0x01取出单个位,赋给开关状态数组。参数上,如果开关是低有效(按下接地),映射后取反即可,或者在读函数里统一异或0xFFFF。这一步看着简单,但实际项目里接线顺序和代码假设不一致是最高频的翻车点,建议第一次调试时逐个短接开关、打印raw值,把映射表核对一遍再往下走。
3.3 软件消抖:机械开关的抖动怎么滤
74HC165读的是电平,机械拨码开关或按键在动作瞬间会有几毫秒抖动,直接读会得到一串跳变。常见做法是“定时采样+连续一致才认”,不需要复杂滤波。比如10ms读一次,连续3次相同才更新状态,对拨码开关足够;按键则可以用20ms间隔、连续2次一致。这里给一个轻量的状态机写法,不占太多RAM。
/* 简易消抖:每10ms调用一次,连续3次一致才更新 */ typedef struct { uint8_t stable; /* 消抖后的稳定状态 */ uint8_t count; /* 连续一致计数 */ uint8_t last; /* 上次采样值 */ } debounce_t; void debounce_update(debounce_t *d, uint8_t sample) { if (sample == d->last) { if (d->count < 3) d->count++; if (d->count >= 3) d->stable = sample; } else { d->count = 0; d->last = sample; } }逻辑说明:每次采样和上次比较,相同则计数累加,累计到3就把稳定状态更新为当前值;不同则计数清零、记录新值重新开始。参数上,3次×10ms=30ms的确认窗口,对拨码开关偏保守但安全,按键可以降到2次。注意这个函数要放在定时器中断或主循环固定节拍里调用,采样间隔不稳会让消抖窗口失真。如果开关数量多,给每个开关配一个debounce_t实例即可,16个也就几十字节。
4. 避坑与排查:读回全1、数据错位、级联丢位怎么查
驱动写完后最常见的三类故障,基本都能从“锁存时序、接线映射、时钟数量”三个方向定位。下面按现象→原因→解决列出来,都是实际调试里反复遇到的。
4.1 读回全是0xFF或全0
现象:不管开关怎么拨,hc165_read16()返回恒定值。原因通常是Q7输入脚配置错(比如误配成输出)、PL没有产生低脉冲(一直高或一直低)、或者芯片供电/接地没接好。解决:先用万用表量Q7在打时钟时是否有高低变化,没有就查PL是否真的拉低过——用示波器看PL引脚,确认有一个低电平窄脉冲;再确认Q7的GPIO模式是输入且上拉使能。如果Q7恒定高,多半是芯片没锁存成功,PL时序是首要嫌疑。
4.2 数据整体错一位或高低8位颠倒
现象:读到的值和预期差一个移位,或者开关1~8和9~16对调。原因一是读循环里data <<= 1和读Q7的先后顺序写反,导致首位丢失;二是级联时两片的Q7'接线端搞反,高低字节顺序颠倒。解决:先单片刻试,只接8个开关,确认单片的位序正确;再上两片,用已知状态(比如全拨到ON)读raw值,看是0xFFFF还是0x00FF,据此判断是移位顺序还是字节顺序问题。位序问题改循环顺序,字节顺序问题改拼装时的移位方向或映射表。
4.3 级联后高8位正常、低8位全错
现象:第一片的8位读得对,第二片的数据全是乱值。原因通常是级联线接错——第二片的Q7'应该接第一片的串行输入脚(不是并行脚),或者时钟只打到了第一片、第二片CP没连上。解决:确认两片CP并联、PL并联,只有数据线是串的;用示波器看第二片CP是否有16个脉冲。另一个隐蔽原因是第二片Q7'输出没加上拉,长线导致电平塌陷,补一个4.7k上拉到VCC即可。
4.4 按键偶发误触发
现象:开关没动,状态偶尔跳变一次。原因是采样节拍不稳或消抖窗口太短,也可能是Q7走线和时钟线并行太长引入耦合。解决:把消抖确认次数从2提到3,采样间隔固定用定时器;硬件上让CP和Q7走线尽量分开,必要时Q7串一个100Ω电阻。软件层面还可以在读函数里连续读两次、两次一致才采用,代价是多花十几个时钟周期,对16路采集完全可接受。
提示:调试74HC165时,先把“锁存脉冲有没有、时钟有没有、数据有没有动”这三件事用示波器确认,再回头看代码,能省掉大半瞎猜的时间。
5. 进阶用法:用DMA+定时器把采集彻底从主循环里摘出去
前面几章的读函数是阻塞式的,16个时钟打下来虽然只有微秒级,但如果系统里有更紧的任务,或者要高频轮询几十路扩展,主循环里反复调用还是占CPU。更彻底的做法是把CP时钟交给定时器PWM或输出比较,Q7接定时器输入捕获或SPI的MISO,用DMA把移位数据搬进缓冲区,主循环只读结果。74HC165的时序本质就是一个“时钟+数据”的串行流,和SPI的Mode 0在电气上能对上(CP空闲低、上升沿采样),所以常见做法是把Q7接到SPI的MISO、CP接SCK,PL用一个GPIO在每次传输前打低脉冲,然后启动一次16位的SPI接收。
/* 用SPI读取两片级联74HC165,SPI Mode 0,16位帧 */ uint16_t hc165_read_spi(void) { uint16_t rx = 0; /* 锁存:PL低脉冲,把并行状态装进移位寄存器 */ HAL_GPIO_WritePin(HC165_PL_PORT, HC165_PL_PIN, GPIO_PIN_RESET); __NOP(); HAL_GPIO_WritePin(HC165_PL_PORT, HC165_PL_PIN, GPIO_PIN_SET); /* 一次16位SPI传输,SCK自动产生16个时钟,MISO收回数据 */ HAL_SPI_Receive(&hspi1, (uint8_t *)&rx, 1, 10); /* 1个16位帧 */ return rx; }逻辑说明:PL低脉冲完成锁存后,SPI主机模式发一次16位接收,硬件自动在SCK上产生16个时钟,74HC165在每个上升沿把数据推到MISO,SPI外设按位收进rx。参数上,SPI要配成16位数据帧、Mode 0(CPOL=0、CPHA=1边沿采样需按芯片手册核对,74HC165是上升沿移出,对应SPI在第一个边沿采样)、波特率别超过芯片上限(手册一般标到几十兆,实际按走线长度降到1~4MHz更稳)。注意SPI的位序要设成MSB first,和74HC165先出高位一致,否则读回来是反的。
这套方案的好处是把16个时钟的时序完全交给硬件,CPU只在传输前后各动一次GPIO,配合DMA还能做到零占用。验证方法很简单:先用阻塞版读函数和SPI版对同一组开关各读一次,两个值相等就说明时序对齐了;不等就查SPI的CPOL/CPHA和位序。我一般会在初始化后跑一次自检,把两版结果打印出来比对,确认一致再切到SPI路径。从那以后我每次给新板子写74HC165驱动,都强制先用阻塞版跑通、再用SPI版替换,两步都留日志,省得后面分不清是硬件还是时序的锅。希望帮到你。
本文还有配套的精品资源,点击获取