STM32多路开关采集:74HC165驱动包设计与级联实现

发布时间:2026/10/29 14:28:17
STM32多路开关采集:74HC165驱动包设计与级联实现

简介:这份资源面向使用STM32等微控制器进行I/O扩展的嵌入式开发者,聚焦74HC165并行转串行移位寄存器的驱动实现,帮助解决GPIO口线不足时如何稳定读取多路外部输入的问题。压缩包共2个文件,以1个.c源文件与1个.h头文件组成,包体约1KB,源文件承载时序控制与数据读取逻辑,头文件提供引脚定义与接口声明,便于直接加入工程调用。目前已有738人学习下载,适合需要快速验证硬件接口方案的中初级开发者参考。内容围绕SH_CP、LE、SRCLR等引脚时序展开,涵盖GPIO初始化、串行时钟控制、数据移入与并行读取等关键环节,可应用于矩阵键盘、多位数码管显示及多通道数据采集等场景。读者可据此理解移位寄存器与STM32的接口设计思路,并在此基础上按实际引脚与时序要求做适配修改,减少从零编写驱动时的调试成本。

1. 一块并转串芯片,为什么值得单独建一个驱动包

做按键矩阵或者拨码开关采集的时候,最省事的做法是把每个开关都接到一个 GPIO 上。八个开关占八个脚,十六个开关占十六个脚,板子画到后面引脚不够用,只能换封装更大的主控,成本一下就上去了。74HC165 这类 8 位并行输入、串行输出的移位寄存器,就是专门解决这个问题的:八路开关只占主控三个脚(时钟、锁存、数据),多片还能级联,十六路、三十二路照样三个脚搞定。这份 74HC165 驱动包面向的就是 STM32 平台上做多路开关采集的场景,把时序、级联、消抖这些容易翻车的地方封装成可复用的驱动层,适合正在画板子、写按键扫描、或者被引脚数量卡住的嵌入式开发者直接拿去改。

2. 驱动架构拆解:三层结构让芯片时序和业务逻辑解耦

2.1 为什么不能把时序代码直接塞进按键扫描函数

很多人在第一次用 74HC165 的时候,习惯在按键扫描函数里直接拉时钟、读数据,几行代码就完事。单路、单芯片、不消抖的情况下确实能跑,但只要出现下面任意一种变化,代码就得推倒重写:芯片从一片变成两片级联、采集从主循环挪进定时器中断、开关从拨码变成机械按键需要消抖、主控从一款 STM32 换到另一款。时序代码和业务代码搅在一起,改一处牵动全身,这是血泪经验。

这份驱动的思路是把职责切成三层,层与层之间只通过明确的接口通信:

层级职责典型文件依赖
硬件抽象层GPIO 读写、微秒级延时hc165_port.c / hc165_port.hHAL 库或寄存器操作
芯片驱动层锁存、移位、级联读取时序hc165.c / hc165.h仅依赖硬件抽象层
应用层按键消抖、状态映射、业务回调main.c 或独立业务模块依赖芯片驱动层

这样分层之后,换主控只需要重写 hc165_port.c 里的几个函数,芯片驱动层一行不动;换芯片数量只需要改一个宏定义;业务层想怎么处理数据都行,驱动层不关心。常见做法是在 hc165.h 里用一个宏HC165_CHAIN_NUM控制级联片数,驱动内部按片数循环读取,应用层拿到的永远是一个完整的位图。

2.2 目录结构与关键文件说明

拿到压缩包解压后,目录大致是这样组织的(不同版本可能略有出入,以实际文件为准):

74HC165_Driver/ ├── Inc/ │ ├── hc165.h # 驱动对外接口、级联片数宏、数据结构定义 │ └── hc165_port.h # 硬件抽象层接口声明 ├── Src/ │ ├── hc165.c # 核心时序实现:锁存、移位、级联读取 │ └── hc165_port.c # GPIO 初始化、电平读写、延时实现 ├── Example/ │ └── main.c # 采集示例:初始化 + 周期读取 + 串口打印 └── README.md # 接线说明与移植步骤

hc165.h是整个驱动的门面,对外只暴露初始化、读取、片选控制这几类接口,内部时序细节全部藏在hc165.c里。hc165_port.c是移植时唯一需要动的地方,里面通常是四个函数:三个引脚初始化、锁存脚控制、时钟脚控制、数据脚读取,外加一个微秒延时。把这几块拆开,移植工作量就从「读懂整个驱动」降到「填四个空」。

2.3 一次完整读取的时序链路

74HC165 的读取分两步:先拉低锁存脚(PL / SH/LD),把八路并行输入的状态锁进芯片内部;然后给时钟脚(CP)打脉冲,数据脚(Q7)在每个时钟上升沿输出一位,从最高位开始往外吐。级联的时候,前一级的串行输出接到后一级的串行输入,时钟和锁存共用,读 N 片就是连续打 8×N 个时钟脉冲。

这里有个容易忽略的细节:74HC165 的时钟有个时钟禁止脚(CE),低电平有效。如果硬件上直接接地,那没问题;如果接在 GPIO 上,初始化时必须拉低,否则时钟打不进去,读出来全是固定值。驱动里一般会在初始化函数里统一处理,但移植时如果发现读出来永远是 0xFF 或 0x00,先查这个脚。

3. 核心代码逐段讲:锁存、移位、级联读取怎么写

3.1 硬件抽象层:四个函数撑起移植工作

先看hc165_port.c里最关键的几个实现。以 STM32 HAL 库为例,引脚定义放在头文件里,方便统一改:

/* hc165_port.h —— 移植时只需要改这里的引脚宏 */ #define HC165_PL_GPIO_PORT GPIOA #define HC165_PL_PIN GPIO_PIN_0 /* 锁存脚 SH/LD */ #define HC165_CP_GPIO_PORT GPIOA #define HC165_CP_PIN GPIO_PIN_1 /* 时钟脚 CP */ #define HC165_Q7_GPIO_PORT GPIOA #define HC165_Q7_PIN GPIO_PIN_2 /* 数据脚 Q7 */ #define HC165_CE_GPIO_PORT GPIOA #define HC165_CE_PIN GPIO_PIN_3 /* 时钟禁止脚,低有效 */
/* hc165_port.c —— 引脚操作与延时 */ void hc165_port_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); /* PL、CP、CE 为输出,Q7 为输入 */ gpio.Pin = HC165_PL_PIN | HC165_CP_PIN | HC165_CE_PIN; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(HC165_PL_GPIO_PORT, &gpio); gpio.Pin = HC165_Q7_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; /* 开关另一端接地时用上拉 */ HAL_GPIO_Init(HC165_Q7_GPIO_PORT, &gpio); /* 初始状态:锁存高、时钟低、时钟禁止拉低使能 */ HAL_GPIO_WritePin(HC165_PL_GPIO_PORT, HC165_PL_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(HC165_CP_GPIO_PORT, HC165_CP_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(HC165_CE_GPIO_PORT, HC165_CE_PIN, GPIO_PIN_RESET); } void hc165_pl_write(uint8_t level) { HAL_GPIO_WritePin(HC165_PL_GPIO_PORT, HC165_PL_PIN, level ? GPIO_PIN_SET : GPIO_PIN_RESET); } void hc165_cp_write(uint8_t level) { HAL_GPIO_WritePin(HC165_CP_GPIO_PORT, HC165_CP_PIN, level ? GPIO_PIN_SET : GPIO_PIN_RESET); } uint8_t hc165_q7_read(void) { return HAL_GPIO_ReadPin(HC165_Q7_GPIO_PORT, HC165_Q7_PIN) == GPIO_PIN_SET; } void hc165_delay_us(uint32_t us) { /* 简单循环延时,主频 72MHz 时约 1us 一次,按实际主频校准 */ volatile uint32_t n = us * 12; while (n--) { __NOP(); } }

这段代码的逻辑很直白:初始化把三个输出脚配好、数据脚配上拉输入,然后给一个确定的初始电平。hc165_delay_us用循环延时,精度不高但够用,因为 74HC165 在 5V 下时钟频率可以到几十 MHz,STM32 的 GPIO 翻转速度远低于芯片上限,真正需要延时的地方是锁存脉冲之后的建立时间,通常几百纳秒就够,循环延时留足余量即可。参数上唯一要校准的是us * 12这个系数,主频不同要按实际调整,调大了读取变慢,调小了可能时序不够。

3.2 芯片驱动层:级联读取的核心循环

hc165.c里最核心的是读取函数。级联片数用宏控制,读取时先锁存,再按位循环:

/* hc165.h —— 对外接口与级联配置 */ #define HC165_CHAIN_NUM 2 /* 级联片数,2 片 = 16 路输入 */ void hc165_init(void); void hc165_read_all(uint8_t *buf, uint8_t chain_num);
/* hc165.c —— 锁存 + 移位读取 */ void hc165_init(void) { hc165_port_init(); } void hc165_read_all(uint8_t *buf, uint8_t chain_num) { uint8_t i, bit; /* 1. 锁存:PL 拉低再拉高,并行数据进入移位寄存器 */ hc165_pl_write(0); hc165_delay_us(1); hc165_pl_write(1); hc165_delay_us(1); /* 2. 逐位读取:每片 8 位,共 chain_num * 8 位 */ for (i = 0; i < chain_num; i++) { buf[i] = 0; for (bit = 0; bit < 8; bit++) { buf[i] <<= 1; /* 左移,先读到的是高位 */ buf[i] |= hc165_q7_read(); /* 读当前数据脚 */ hc165_cp_write(1); /* 时钟上升沿,芯片移出一位 */ hc165_delay_us(1); hc165_cp_write(0); hc165_delay_us(1); } } }

逐段说明。第一步锁存:PL 低电平期间并行输入被采样,拉高后数据冻结在移位寄存器里,这个脉冲宽度 datasheet 要求最小几十纳秒,代码里给 1 微秒是保守做法。第二步循环:外层按片数走,内层按位走,每读一位之前先左移腾出最低位,再把数据脚电平或进去,然后打一个时钟脉冲让芯片移出下一位。注意顺序是先读后打时钟,因为第一个数据位在锁存后就已经出现在 Q7 上了,如果先打时钟再读,会丢掉最高位、多读一位无效数据,这是新手最常翻车的地方。

参数上,buf是调用者提供的缓冲区,长度至少chain_num字节;chain_num传级联片数,和宏HC165_CHAIN_NUM保持一致。级联时数据顺序是:第一片的 8 位先出来,然后是第二片,依此类推,所以buf[0]对应第一片,接线时把离主控最近的那片当第一片,映射关系最直观。

3.3 应用层:把位图变成按键状态

驱动层吐出来的是原始位图,应用层要做的第一件事是消抖。机械按键抖动通常在 5 到 20 毫秒,常见做法是定时器每 10 毫秒采一次,连续两次相同才认:

/* main.c —— 周期采集 + 简单消抖 */ static uint8_t last_raw[HC165_CHAIN_NUM]; static uint8_t stable[HC165_CHAIN_NUM]; void key_scan_task(void) /* 每 10ms 调用一次 */ { uint8_t raw[HC165_CHAIN_NUM]; uint8_t i; hc165_read_all(raw, HC165_CHAIN_NUM); for (i = 0; i < HC165_CHAIN_NUM; i++) { if (raw[i] == last_raw[i]) { stable[i] = raw[i]; /* 连续两次一致,认为稳定 */ } last_raw[i] = raw[i]; } /* stable[] 里就是消抖后的状态,可映射成按键事件 */ }

这段逻辑用「连续两次采样一致」代替复杂的计数器消抖,代码短、内存占用小,对拨码开关和响应要求不高的按键足够用。如果按键要求快速响应,可以把采样周期缩到 5 毫秒,或者改成计数式消抖。stable数组的每一位对应一路开关,取反还是取正取决于硬件接法:开关一端接地、芯片输入上拉时,按下读到 0,业务层按需翻转。

4. 跑起来:从接线到串口看到数据

4.1 硬件接线与上电检查

接线是这类驱动最容易出问题的地方,按下面的对应关系接,接完先别急着烧程序,用万用表量一遍:

74HC165 引脚接到哪里说明
VCC / GND3.3V / GND和主控共地,别用 5V 直连 3.3V 主控
SH/LD(PL)PA0锁存,驱动输出
CPPA1时钟,驱动输出
CEPA3 或 GND低有效,接 GPIO 时初始化拉低
Q7PA2串行数据,驱动输入
D0~D7开关 / 拨码每路一端接输入、一端接地,输入上拉
Q7'(级联)下一片 DS级联时前片 Q7' 接后片串行输入

上电后先量三件事:VCC 是不是 3.3V、CE 是不是低电平、PL 初始是不是高电平。这三项任何一项不对,读出来都是固定值,后面调代码全是白费功夫。

4.2 移植到自己的工程

把Inc和Src下的文件加进工程,然后按三步走。第一步改hc165_port.h里的引脚宏,换成自己板子实际用的引脚;第二步确认hc165_port.c里的 GPIO 时钟使能对应的是哪个端口,比如用 GPIOB 就要改成__HAL_RCC_GPIOB_CLK_ENABLE();第三步在main.c里初始化并周期调用:

/* main.c —— 初始化与主循环 */ int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); /* 串口用于打印调试 */ hc165_init(); while (1) { uint8_t raw[HC165_CHAIN_NUM]; hc165_read_all(raw, HC165_CHAIN_NUM); printf("raw: %02X %02X\r\n", raw[0], raw[1]); HAL_Delay(100); } }

烧进去之后打开串口,拨动开关,看打印的十六进制值有没有对应位翻转。如果两片级联,raw[0]对应第一片、raw[1]对应第二片,逐位对照接线表验证。这一步跑通,说明时序和接线都没问题,再往上叠消抖和业务逻辑。

4.3 用示波器确认时序

串口数据不对的时候,别急着改代码,先看波形。把探头挂在 CP 脚上,触发方式设成下降沿,应该能看到一串等间隔的脉冲,数量等于 8×级联片数;再把探头挂 PL 上,每次读取前应该有一个低脉冲。如果 CP 上脉冲数量不对,查循环里的片数参数;如果 PL 没有脉冲,查锁存函数有没有被调用;如果脉冲都有但数据不变,查 Q7 接线和 CE 电平。这套排查顺序比盲改代码快得多。

5. 避坑与排查:五个真实踩过的坑

5.1 读出来永远是 0xFF 或 0x00

现象:串口打印固定值,拨开关毫无反应。原因通常是三类:CE 脚没拉低,时钟被禁止;Q7 数据脚配置成了输出或者没配上拉;锁存脉冲根本没发出来。解决:先量 CE 电平,再确认 Q7 是输入模式,最后用示波器看 PL 有没有脉冲。三个都排除,基本就是接线虚焊。

5.2 级联后数据错位一位

现象:单片正常,两片级联后第二片的数据整体偏移。原因多半是读取循环里先打时钟后读数据,或者级联线接反了——把第一片的 Q7' 接到了主控、第二片的数据脚悬空。解决:确认循环内是先读后打时钟,再按「离主控最近的是第一片」重新核对级联线。

5.3 按键偶尔误触发

现象:没碰开关,状态偶尔跳变。原因是采样周期和开关抖动撞上了,或者数据线太长引入干扰。解决:把采样周期固定到 10 毫秒并加连续两次一致的消抖;数据线超过十厘米时在 Q7 上加 100nF 到地,或者降低时钟频率、增加位间延时。

5.4 换主控后编译不过

现象:换了一款 STM32,hc165_port.c里一堆未定义。原因是 HAL 库版本或者寄存器操作方式不同。解决:只改hc165_port.c,把 HAL 调用换成新平台的等价写法,驱动层和头文件不动。这也是分层设计的意义所在,移植成本被限制在一个文件里。

5.5 中断里读取导致数据错乱

现象:把读取放进定时器中断后,偶尔读到半截数据。原因是中断里调用了带延时的函数,或者和主循环里的读取发生重入。解决:读取函数本身不加锁,但调用方要保证同一时刻只有一处调用;中断里读取时把延时函数换成更短的忙等,或者干脆在主循环读取、中断只置标志。

6. 二次开发:把驱动改成适合自己项目的形状

6.1 三个最值得改的方向

拿到这份驱动,直接能用,但真正落地时通常要按项目改三处。第一处是级联片数,HC165_CHAIN_NUM改成实际片数,缓冲区大小跟着变,如果片数超过 8 片,uint8_t数组要换成uint16_t或者按字节数组处理,读取循环里的位操作也要相应调整。第二处是消抖策略,拨码开关不需要消抖,机械按键需要,可以给驱动加一个编译开关,拨码模式下跳过消抖直接输出。第三处是数据映射,把原始位图映射成业务语义,常见做法是加一张映射表:

/* 位图到业务 ID 的映射,按实际接线填 */ static const uint8_t bit_to_key[HC165_CHAIN_NUM][8] = { { 0, 1, 2, 3, 4, 5, 6, 7 }, /* 第一片 D0~D7 */ { 8, 9, 10, 11, 12, 13, 14, 15 } /* 第二片 D0~D7 */ };

映射表的好处是接线变了只改表,业务代码不动。如果项目里开关数量固定、语义明确,这张表比在代码里到处写if (raw & 0x01)好维护得多。

6.2 验证改造是否正确的两个方法

改完之后怎么确认没改坏?第一个方法是回环验证:把某一路输入固定接地或接高,读出来对应位应该稳定为 0 或 1,逐位过一遍,确认映射表每一位都对得上。第二个方法是边界验证:级联片数改大之后,把最后一片的输入全部拉高、前面全部拉低,读出来应该只有最后一片的字节是 0xFF,其余是 0x00,这个测试能一次性抓出循环边界和缓冲区越界的问题。

6.3 一个容易被忽略的优化点

如果采集频率要求高,比如每毫秒读一次,循环延时就成了瓶颈。可以把hc165_delay_us里的循环延时换成基于 DWT 周期计数器的精确延时,或者干脆去掉位间延时——74HC165 的建立保持时间在几十纳秒量级,STM32 的 GPIO 翻转本身就够慢,多数情况下不加延时也能稳定工作。但去掉延时之前一定用示波器确认波形,别凭感觉删。

从那以后我每次移植这类移位寄存器驱动,都强制先量 CE、PL、CP 三个脚的电平再动代码,因为十次读不出数据里有八次是硬件状态不对,代码本身反而是最不容易错的部分。希望帮到你。

本文还有配套的精品资源,点击获取