跳转到内容
新建笔记

STM32 板卡 IO 测试:8 路输入、独立消抖与输出更新

这是一份 STM32F1 板卡 IO 测试方案:PB8~PB15 的 8 路开关输入产生稳定按下事件后,按所选规则翻转 PC0~PC7 的输出命令;可选用 PA15 显示最近一次事件。它用于核对输入、输出和接线,不是实际机器的自动运行程序;执行前应确认所连接的测试负载及其有效极性。

早期记录把输入写成“PB8~PB16,共 9 路”,并使用不存在的 GPIO_PIN_16。STM32F1 一个 GPIO 端口的编号是 0~15,官方 HAL 也只定义 GPIO_PIN_0 到 GPIO_PIN_15。本页因此把可执行示例限定为 PB8~PB15 的 8 路;若原板确实有第 9 路,必须先查原理图确定它的真实端口,不能猜成 PB16。ST 官方 GPIO 引脚定义

先固定电气约定与事件规则

跳转到“先固定电气约定与事件规则”
引脚本例配置软件含义
PB8~PB15上拉输入,开关闭合接地按下对应输入位为 1;PB8 对应 bit 0
PC0~PC3普通推挽输出命令位 0 拉低,1 拉高
PC4~PC7普通开漏输出命令位 0 拉低,1 释放;外部上拉和负载决定释放后的电压
PA15(可选)普通推挽输出,外接高有效指示灯电路最近一次稳定按下事件后置高,经过 1000 ms 后置低

这些引脚必须在选定型号和封装中实际引出,且未被其他外设占用。STM32F103C8T6 的 LQFP48 封装没有引出 PC0~PC7,不能照搬本页的输出接线;其代码能通过编译也不代表引脚存在。需使用实际引出这些引脚的器件与封装,或根据原理图重写输出映射;本页不猜测替代引脚。STM32F103x8/xB 数据手册,LQFP48 引脚图与引脚表

PC4~PC7 需要合适的外部上拉;F1 输出模式里的 GPIO_PULLUP 不能替代它。输入电源、输出上拉电源、阈值及地参考应相容。PA15 的高有效约定也须由电路确认,不能套用板载低有效 LED 的接法。

每路输入独立持续稳定 50 ms 才接受变化;只在接受按下时产生事件,保持按住不重复触发。上电时已按住的开关被当作初始状态,不立即触发,必须先稳定释放再按下。低有效引脚的按下是电气下降沿,归一化为“按下为 1”后则是逻辑上升沿。

模式本轮接受的事件掩码 events输出变化
IO_TOGGLE_ALL(默认)任意非零值8 路只翻转一次:异或 0xFF
IO_TOGGLE_EACH每个 1 对应本轮新按下的一路仅对应位翻转:异或 events

例如初值为 0x00,同一轮接受 PB8 和 PB10 时,EACH 得到 0x05,ALL 得到 0xFF。ALL 不按按键数量循环翻转;若两键在不同轮询分别达到稳定门限,则各翻转一次。PA15 在任意非零事件后重新计时,保持亮到最近一次事件满 1000 ms;同一轮既到期又出现新事件时,保持亮并从新事件重新计时。

两份原始代码使用“距离上次事件是否超过 50 ms”的全局门限,没有确认每个新状态已连续稳定。例如空闲很久后的短低脉冲可能立即触发,另一键的合法按下又可能受全局门限影响。不能把两次事件之间的限速当作消抖,也不能把未滤波的释放立即当作下一次按下的起点。

下面的纯 C 部分不依赖 STM32 HAL,保存为 input_logic.h。输入 pressed 已归一化为“1 表示按下”,返回值每个 1 表示这一轮刚接受的按下事件。释放也会更新稳定状态,但不返回按下事件。

#ifndef INPUT_LOGIC_H
#define INPUT_LOGIC_H
#include <stdbool.h>
#include <stdint.h>
typedef struct {
uint8_t stable;
uint8_t candidate;
uint32_t since_ms[8];
} InputFilter;
static void Inputs_Init(InputFilter *filter, uint8_t pressed, uint32_t now_ms)
{
filter->stable = pressed;
filter->candidate = pressed;
for (unsigned i = 0; i < 8; ++i) filter->since_ms[i] = now_ms;
}
static uint8_t Inputs_Update(InputFilter *filter, uint8_t pressed,
uint32_t now_ms)
{
uint8_t events = 0;
for (unsigned i = 0; i < 8; ++i) {
uint8_t bit = (uint8_t)(1u << i);
if (((pressed ^ filter->candidate) & bit) != 0u) {
filter->candidate ^= bit;
filter->since_ms[i] = now_ms;
} else if (((filter->candidate ^ filter->stable) & bit) != 0u &&
(uint32_t)(now_ms - filter->since_ms[i]) >= 50u) {
filter->stable ^= bit;
if ((filter->stable & bit) != 0u) events |= bit;
}
}
return events;
}
static uint32_t Outputs_Bsrr(uint8_t state)
{
return (uint32_t)state | (((uint32_t)(~state) & 0xFFu) << 16u);
}
#endif

每路都有独立候选值和计时起点,一个开关抖动不会重置另一开关的计时。50 ms 沿用原笔记的门限,实际应根据开关、响应速度与采样周期调整;采样之间发生的变化不可见。

Outputs_Bsrr() 只构造 PC0~PC7 的设置/清零位,不改 PC8~PC15。它在一个 BSRR 写入中更新八个输出锁存位,避免原文八次 HAL 调用依次更新时的中间软件状态;引脚外部波形仍存在传播差异,不能据此保证纳秒级同时动作。RM0008,GPIO 原子位访问

两种翻转模式与指示计时

跳转到“两种翻转模式与指示计时”

保存为 io_test_logic.h。状态中的 indicator_on 是软件输出命令,计时不依赖读取 PA15 引脚。指示功能关闭时始终为假;这使滤波、翻转与一秒到期行为都能在主机上测试。

#ifndef IO_TEST_LOGIC_H
#define IO_TEST_LOGIC_H
#include "input_logic.h"
typedef enum {
IO_TOGGLE_ALL = 0,
IO_TOGGLE_EACH = 1
} IOToggleMode;
typedef struct {
InputFilter inputs;
uint8_t output_state;
IOToggleMode mode;
bool indicator_enabled;
bool indicator_on;
uint32_t indicator_since_ms;
} IOTestState;
static void IOLogic_Init(IOTestState *state, uint8_t pressed, uint32_t now_ms,
uint8_t initial_state, IOToggleMode mode,
bool indicator_enabled)
{
Inputs_Init(&state->inputs, pressed, now_ms);
state->output_state = initial_state;
state->mode = mode;
state->indicator_enabled = indicator_enabled;
state->indicator_on = false;
state->indicator_since_ms = now_ms;
}
static uint8_t IOLogic_Update(IOTestState *state, uint8_t pressed,
uint32_t now_ms)
{
uint8_t events = Inputs_Update(&state->inputs, pressed, now_ms);
if (events != 0u) {
uint8_t mask = state->mode == IO_TOGGLE_EACH ? events : UINT8_MAX;
state->output_state ^= mask;
if (state->indicator_enabled) {
state->indicator_on = true;
state->indicator_since_ms = now_ms;
}
}
if (state->indicator_on &&
(uint32_t)(now_ms - state->indicator_since_ms) >= 1000u) {
state->indicator_on = false;
}
return events;
}
#endif

由初始化函数选择模式与是否启用指示灯,在单一执行者内使用;本例不提供运行中直接改状态字段的接口。1000 ms 是事件被滤波接受后的软件时长,到期后在下一次轮询落实到引脚,因此实际高电平时间还受轮询间隔影响。

保留 CubeMX 生成的工程、启动文件、系统时钟与错误处理,不要把整份别的 main.c 覆盖进来。下面两段按顺序放在同一 io_test.c;头文件使用前两段,且工程已包含 STM32F1 HAL。GPIO 映射要求仍以实际封装为准。

#include "stm32f1xx_hal.h"
#include "io_test_logic.h"
static uint8_t Read_Input_Switches(void)
{
return (uint8_t)((~(GPIOB->IDR >> 8u)) & 0xFFu);
}
static void Update_Outputs(uint8_t state)
{
GPIOC->BSRR = Outputs_Bsrr(state);
}
static void IO_Test_GPIO_Init(uint8_t initial_state)
{
GPIO_InitTypeDef gpio = {0};
__HAL_RCC_GPIOB_CLK_ENABLE();
__HAL_RCC_GPIOC_CLK_ENABLE();
Update_Outputs(initial_state); /* 开启输出前先预装命令 */
gpio.Pin = GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11
| GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15;
gpio.Mode = GPIO_MODE_INPUT;
gpio.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOB, &gpio);
gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3;
gpio.Mode = GPIO_MODE_OUTPUT_PP;
gpio.Pull = GPIO_NOPULL;
gpio.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &gpio);
gpio.Pin = GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7;
gpio.Mode = GPIO_MODE_OUTPUT_OD;
HAL_GPIO_Init(GPIOC, &gpio);
}
static void Indicator_GPIO_Init(void)
{
GPIO_InitTypeDef gpio = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_AFIO_CLK_ENABLE();
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_15, GPIO_PIN_RESET);
__HAL_AFIO_REMAP_SWJ_NOJTAG(); /* 释放 JTAG 引脚,保留 SWD */
gpio.Pin = GPIO_PIN_15;
gpio.Mode = GPIO_MODE_OUTPUT_PP;
gpio.Pull = GPIO_NOPULL;
gpio.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &gpio);
}

读取时先截取 PB8~PB15,再取反归一化。其他 PB 引脚的状态不会进入开关位图;端口一次读取也是一个采样时刻,不能证明此前 50 ms 内没有遗漏短脉冲。

PA15 复位后承担 JTDI。只有确认该引脚已接高有效指示电路、未被其他外设占用,且项目可以放弃 JTAG 时,才启用上述可选初始化。__HAL_AFIO_REMAP_SWJ_NOJTAG() 关闭 JTAG-DP、保留 PA13/PA14 的 SWD;不要换成同时关闭 SWD 的 __HAL_AFIO_REMAP_SWJ_DISABLE()。若工程已由 CubeMX 选择 Serial Wire 并统一管理 AFIO 配置,应在同一初始化方案里合并,而不是让之后的代码重新覆盖。ST 官方 HAL 的 SWJ 配置说明

初始化命令必须根据测试电路选择。原文直接把 0x00 称为“全灭”,但开漏 0 会吸收电流,低有效负载也可能因此动作。本页不替未知负载指定“安全电平”。在隔离的指示灯测试夹具上确认极性后,由调用方传入所需初值:

static IOTestState io_state;
void IO_Test_Init_Config(uint8_t initial_state, IOToggleMode mode,
bool enable_pa15_indicator)
{
IO_Test_GPIO_Init(initial_state);
if (enable_pa15_indicator) Indicator_GPIO_Init();
IOLogic_Init(&io_state, Read_Input_Switches(), HAL_GetTick(),
initial_state, mode, enable_pa15_indicator);
}
void IO_Test_Init(uint8_t initial_state)
{
IO_Test_Init_Config(initial_state, IO_TOGGLE_ALL, false);
}
uint8_t IO_Test_Poll(void)
{
uint32_t now_ms = HAL_GetTick();
bool indicator_was_on = io_state.indicator_on;
uint8_t events = IOLogic_Update(&io_state, Read_Input_Switches(), now_ms);
if (events != 0u) Update_Outputs(io_state.output_state);
if (io_state.indicator_enabled &&
indicator_was_on != io_state.indicator_on) {
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_15,
io_state.indicator_on ? GPIO_PIN_SET : GPIO_PIN_RESET);
}
return events;
}

在 HAL、系统时钟准备完成后调用一次 IO_Test_Init(initial_state),默认保持全组翻转且不占用 PA15。若已核对板卡条件,可改用 IO_Test_Init_Config(initial_state, IO_TOGGLE_EACH, true) 来启用一一对应翻转和指示灯;两种初始化入口只选一个。

周期调用 IO_Test_Poll(),例如约每 1~10 ms 一次;函数不等待开关释放。它与初始化函数及输出寄存器由同一个执行者管理;中断或其他任务不要同时改同一状态。时间差使用无符号 32 位运算,可跨正常毫秒计数回绕。调用不能相隔整个计数周期;如果时基暂停、复位或不再按毫秒递增,必须重新考虑滤波行为。在 RTOS 里,调度等待应使用适合该系统的 API,而不是让高优先级任务忙等。

系统时钟、日志与 CubeMX 边界

跳转到“系统时钟、日志与 CubeMX 边界”

两份原始工程采用不同的时钟方案,合并时保留上下文,不把它们混成同一套参数:

原记录PLL 输入与倍频HCLK/AHBAPB1APB2
板卡 IO 测试(150943376)HSI 8 MHz ÷ 2 × 1664 MHz32 MHz(HCLK ÷ 2)64 MHz(HCLK ÷ 1)
八个按键对应输出(150961341)HSE 8 MHz ÷ 1 × 972 MHz36 MHz(HCLK ÷ 2)72 MHz(HCLK ÷ 1)

后一个工程的代码使用 RCC_HSE_ON、HSE 作为 PLL 源、9 倍频及 FLASH_LATENCY_2,同时保持 HSI 打开。这要求板上确有对应的 8 MHz HSE 电路,并正确设置工程的 HSE_VALUE;若是外部有源时钟,应按实际电路配置 bypass,不能直接套用晶振模式。两种方案都属于特定 F1 器件条件下的工程配置,不是所有 STM32 的默认参数。应在准确型号、供电条件与参考手册限制下设置 Flash 等待周期和各总线频率;已有工作时钟没有必要为了 IO 测试强行替换。

新增原文把 printf 重定向到 USART2,在启动、初值、按键和指示灯熄灭时记录日志。这些观察点可以保留,但逐字符 HAL_UART_Transmit(..., HAL_MAX_DELAY) 可能阻塞扫描,打乱滤波采样和熄灯时间;本页轮询路径因此不直接打印。需要日志时,由扫描路径把时间、events、输出命令、指示状态放入有界、立即返回的日志队列,再由独立发送方使用 USART2 中断或 DMA 发送;满队列应丢弃并计数,不能等待空间。异步发送缓冲区须存活到传输结束,不可直接传入随后失效的栈缓冲区。USART2 的时钟、引脚、波特率与 IRQ/DMA 配置仍由实际工程提供。

原文启动行把 HAL_GetTick() 打成 HCLK,单位也写成 ms;它只是毫秒时间戳。频率应取 HAL_RCC_GetHCLKFreq() 并标成 Hz,时间戳另取 HAL_GetTick() 并标成 ms。两者不能互相替代。

GPIO 与 CubeMX 的生成代码不能重复拥有两套互相覆盖的初始化逻辑。可以让 CubeMX 生成相同配置,也可以把本页适配函数作为唯一配置入口;重新生成工程后检查用户代码区、AFIO 和初始输出顺序。原来的 Error_Handler() / assert_failed() 骨架由工程保留,本页不重复定义,也不声称错误处理已经覆盖产品全部故障。

所有扩展都基于 8 位输出命令,不是未经确认的“亮/灭”状态。在 IOLogic_Update() 的非零事件分支中替换输出变化规则即可,并保留后续指示计时;下面三个片段是互斥选择,output_state 表示该状态中的输出命令。

/* 单点循环,必须先给非零种子,例如 0x01;0x00 循环仍为 0 */
output_state = (uint8_t)((output_state << 1u) | (output_state >> 7u));
/* 8 位计数,255 后回到 0 */
output_state = (uint8_t)(output_state + 1u);
/* 模式表:每次事件选择下一种 */
static const uint8_t patterns[] = {0x00, 0xFF, 0xAA, 0x55, 0x0F, 0xF0};
static unsigned pattern_index = 0;
output_state = patterns[pattern_index];
pattern_index = (pattern_index + 1u) % 6u;

若不同开关执行不同操作,使用已经消抖的事件位,例如 PB8 选择 0xFF、PB9 选择 0x00、PB10 选择 0xAA。必须规定同时按下时的优先级:独立连续 if 会由后一个匹配覆盖前一个,if/else if 则表达固定优先级。不能直接在每次轮询按当前原始电平重复执行命令。

先核对各开关位图是否对应实际引脚,再分别测推挽高/低、开漏下拉/释放及上电状态;随后测试抖动、保持、释放、快速重复、两键同时按下和不同步按下。分别确认 EACH、ALL 的同时事件语义,并测 PA15 第一次点亮、连续事件延长、到期熄灭和禁用时不占引脚。需要可追踪结果时记录时间、事件位图、输出命令与测量反馈,不能只记录“测试通过”。

本次完整阅读 150961341 原文、既有板卡 IO 测试审查稿及其中全部代码,保留已审查的每键稳定滤波,补入逐位翻转、PA15 指示与 USART2/72 MHz 工程上下文。纯 C 代码已在主机严格编译测试:短脉冲不触发、两路独立消抖、保持不连发、释放再按、同时/错时事件、上电按住、时间回绕、两种翻转和指示灯到期/重触发,以及全部 256 种输出命令的 BSRR 位更新范围。正文代码经提取后,核心与 HAL 组合在官方 STM32F1/CMSIS 头文件下通过 Cortex-M3 编译检查;这不包含完整工程链接、板卡下载或通电验证。

来源:板卡 IO 测试原文;八个按键一一对应八个输出,后者首次发布于 2025-08-28。