简介面向STM32初学者的震动检测报警完整工程代码基于单片机、震动传感器、OLED屏与蜂鸣器实现震动信号采集、阈值判断、OLED信息显示、声音报警以及数据经串口发送至调试助手。可应用于防盗报警、机械设备状态监测、便携式震动检测等方向既适合练习外设驱动也可作为物联网报警项目的起点。压缩包共232个文件大小6.44MB内含C语言源码、头文件、工程配置文件、编译中间文件与清理脚本能够满足从阅读代码到重新编译运行的完整流程对工程分析很有帮助。目前已有138人浏览学习整体代码结构较规范包括OLED显示、定时器扫描、串口发送等模块保留的编译中间文件也便于检查编译细节适合在开发板验证或毕业设计中作为基础框架复用并扩展功能也有助于深入理解编译与链接过程。1. 震动报警系统的构成与选型快递运输、机房设备、机械加工这几类场景里监控震动最怕的不是传感器没检测到而是误报把正常颠簸当成破坏性撞击。这套基于 STM32F103 的震动报警系统把震动传感器、OLED、蜂鸣器和串口串成一条完整链路核心就是要解决采到什么信号、怎么判、怎么报、怎么把数据交给上位机这四个环节。适合做安防设备、工业监测或智能硬件原型也适合正在学标准外设库的开发者拆开来读。从工程文件能明显看出它是 Keil 标准外设库的项目不是现在常见的 HAL 库。标准库的代码更贴近寄存器配合 Keil 调试视图可以直接看到外设寄存器变化对理解 GPIO、ADC、I2C、USART 之间的时序配合比 HAL 库直观得多。先看信号链路的第一环震动传感器怎么接入。2. 震动传感器接入与ADC采集阈值判定2.1 震动传感器的输出类型与STM32接口市场上的震动传感器模块大致分两类。一类是带比较器的数字模块比如 SW-420模块上的 LM393 会把振动信号整形成高低电平DOUT 直接接 GPIO 就能用另一类是模拟输出比如压电陶瓷片加信号调理电路输出幅度随振动强度连续变化。这套工程源码里包含 stm32f10x_adc.c说明采集走了 ADC 方式接的是模拟输出信号或者电位器分压后的波形。接线时 PA1 配置为模拟输入传感器 VCC 接 3.3V如果传感器内部没有电平转换不建议直接接 5V因为部分模拟输出在 3.3V 单片机上会被钳位采样值线性度会变差。注意区分数字传感器的 DOUT 接 GPIO 后只能判断“有无震动”而 ADC 采集可以保留振幅信息方便后面做两级阈值区分“轻微碰撞”和“持续震动”。这也是为什么该工程宁可占用 ADC 和采样时间而不直接读电平的原因。对工业监测来说这个区分是有实际价值的轻微碰撞可能只是维护操作持续强烈震动才需要报警。2.2 ADC1 单次转换模式配置ADC 配置用标准库的 ADC_InitTypeDef 结构体关键是把采样模式定在独立单次上触发方式选软件触发这样主循环什么时候想采就什么时候采不需要额外开 DMA 或中断代码结构最直观。代码如下/* 基于标准外设库的 ADC1 初始化PA1 模拟输入 */ void ADC1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; /* 打开 GPIOA 和 ADC1 时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); /* ADC 时钟 72MHz / 6 12MHz必须低于 14MHz */ RCC_ADCCLKConfig(RCC_PCLK2_Div6); GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; // 模拟输入 GPIO_Init(GPIOA, GPIO_InitStructure); ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode DISABLE; ADC_InitStructure.ADC_ContinuousConvMode DISABLE; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel 1; ADC_Init(ADC1, ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); } /* 单次转换读取采样值返回 0~4095 的 12 位结果 */ uint16_t ADC_Read(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 软件触发转换 while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); // 等待转换完成 return ADC_GetConversionValue(ADC1); // 读数据寄存器 }几个参数值得解释。RCC_PCLK2_Div6 把系统主频 72MHz 分频到 12MHz超过 14MHz 会有转换不准的问题这是超频后 ADC 数值漂移的一个隐蔽原因。ADC_SampleTime_239Cycles5 是最大采样时长震动信号本身不是高频信号采样时间拉长能压掉一部分毛刺等效于硬件上的低通滤波。连续转换模式在这种场景不需要主循环里按需采一次就行否则还要额外处理数据存储逻辑给后面的 OLED、串口带来不必要的耦合。2.3 阈值分段与连续采样防抖震动判定最容易踩的坑是单次采样超限就触发报警。真实场景里一个轻微的机械抖动或者电磁干扰都会在 ADC 值上产生一个尖峰。所以我一般会把阈值分成三段并且采用“连续 N 次中至少 N-1 次超限才确认”的方式场景参考范围0~4095处理动作静态或环境噪声小于 800正常显示轻微碰撞或敲击800~2000OLED 警示不报警持续强烈震动大于 2000蜂鸣器报警 串口上报具体判定代码/* 连续采样判定threshold 为阈值count 为采样次数 */ uint8_t Vibe_Check(uint16_t threshold, uint8_t count) { uint8_t i, overtime 0; for (i 0; i count; i) { if (ADC_Read() threshold) overtime; Delay_ms(2); // 打散采样点避免在同一个振动周期内重复计数 } if (overtime count - 1) // 连续 count 次里超过阈值达到 count-1 次 return 1; return 0; }count 一般取 5间隔 2ms 到 5ms 比较合适。间隔太短等于在同一个震动波形上采多次失去防抖意义间隔太长又会把真实的短促撞击漏掉。如果应用场景是电机这类固定频率震源还可以把间隔设为与震源周期错开的数值做异步采样来避免共振频率干扰。3. I2C OLED显示驱动与状态界面布局3.1 SSD1306 I2C 接口与硬件配置0.91 寸 128×32 的 OLED 模块是这类报警系统最常见的显示配置。工程文件里有 stm32f10x_i2c.c说明 OLED 接到 STM32 的硬件 I2C1 上默认引脚是 PB6SCL和 PB7SDA。SSD1306 芯片的 I2C 7 位地址出厂通常是 0x3C若模块背面 SA0 电阻焊接位置不同也可能是 0x3D。通信时发送地址需要左移一位即0x3C 1这是新手最容易卡住的地方逻辑分析仪上看到一直 NACK多半就是地址没移位或者地址本身就错了。提示0.91 英寸 OLED 的 I2C 地址不是固定的SA0 引脚悬空或接法差异会产生 0x3C 和 0x3D 两个地址先确认模块丝印或扫描 I2C 总线后再改代码。引脚功能配置PB6I2C1_SCL复用开漏输出外接上拉PB7I2C1_SDA复用开漏输出外接上拉硬件 I2C 在 F103 上有个经典坑如果仿真单步执行时卡在 I2C_CheckEvent 的 while 循环里先确认引脚是不是配成了复用开漏而不是推挽再检查模块是否自带 I2C 上拉。这两点都没问题仍然卡死的话换成 IO 模拟 I2C 是大家最常用的应急方案驱动接口不变只改底层读写函数。3.2 驱动初始化序列与单字节写入SSD1306 初始化命令序列不用把手册里所有配置都搬来最小集是关显示、设置寻址模式、开启电荷泵、设置对比度、开显示顺序错乱会导致花屏或不满屏。写入时控制字节 0x00 表示后续是命令0x40 表示后续是数据标准库 I2C 里这一步很容易被忽略/* 向 SSD1306 写入一个字节type0 命令type1 数据 */ void OLED_WriteByte(uint8_t type, uint8_t dat) { uint8_t control type ? 0x40 : 0x00; I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0x3C 1, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, control); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_SendData(I2C1, dat); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_GenerateSTOP(I2C1, ENABLE); } /* 初始化序列关显示、水平寻址、电荷泵开、开显示 */ void OLED_Init(void) { Delay_ms(100); // 等待模块内部 VDD 稳定 OLED_WriteByte(0, 0xAE); // 关闭显示 OLED_WriteByte(0, 0x20); OLED_WriteByte(0, 0x00); // 水平寻址模式 OLED_WriteByte(0, 0x8D); OLED_WriteByte(0, 0x14); // 开启电荷泵 OLED_WriteByte(0, 0xAF); // 打开显示 OLED_Clear(); // 清空显存 }驱动流程里最容易被忽略的是 OLED_Init 开头的延时。模块上电瞬间如果马上发命令部分 SSD1306 会丢首个字节导致后续画面花屏。Delay_ms(100) 是对各批次屏幕最保险的取值。OLED_Clear 是对 128×32 的显存区域全部写入 0x00SSD1306 带内部显存写完数据后不需要额外刷新指令。3.3 字符输出与 128×32 界面分区128×32 只有 4 行 8 像素高的字符空间布局上我会把它分成两个区域第一屏显示状态和阈值第二屏显示震动计数值。字符输出函数的核心是取模数据。西文字符用 5×8 点阵每个字符 8 字节中文用 16×16 点阵每个汉字 32 字节取模方式选择“列行式 阳码”生成的数据才能直接按列写入 SSD1306 的页地址。网上大量 OLED 汉字取模教程的问题基本都出在行列模式选择不一致上。用以下方式输出 16×16 汉字/* 按 16x16 点阵在 (x, y) 处显示汉字HZ_Data 为取模数组 */ void OLED_ShowChinese(uint8_t x, uint8_t y, const uint8_t *HZ_Data) { uint8_t i, j; for (i 0; i 2; i) // 上下两页 { OLED_SetPos(x, y i * 8); // 设置页地址和列地址 for (j 0; j 16; j) // 每页 16 列 OLED_WriteByte(1, HZ_Data[i * 16 j]); } }OLED_SetPos 实际就是设置列地址低字节、高字节和页地址。显示“震动报警”四个字时注意 128 像素宽最多放 8 个 16×16 汉字行高 32 像素放 2 行。我在工程里通常把状态行放在第 0 行显示 NORMAL 或 ALARM第 1 行显示阈值范围第 2、3 行留给计数。这样信息密度正好不会因为刷新频繁导致闪烁。4. 蜂鸣器驱动电路与间断报警时序4.1 有源蜂鸣器与无源蜂鸣器的区别蜂鸣器报警部分要先判断有源还是无源类型。有源蜂鸣器内部自带振荡电路给它一个直流高电平就会响控制逻辑最简单无源蜂鸣器的“无源”指的是内部没有振荡源需要外部提供一定频率的方波或 PWM 信号才能发声。判断方法很简单拿 3.3V 直流电接上去直接响的是有源不响的是无源。工程里有 stm32f10x_tim.c如果板子上用无源蜂鸣器这个 TIM 大概率服务于 PWM 发声如果用有源蜂鸣器TIM 更多是提供延时或系统节拍。从报警效果看无源蜂鸣器可以通过改变 PWM 频率形成长短不同的声音序列比单频长响更能传递紧急程度。代价是代码多一层定时器配置而且 PWM 占空比和频率需要标定不同批次的蜂鸣器谐振频率差异不小装机后往往还要实际听一下再调参数。4.2 三极管驱动电路与 PWM 输出配置STM32 GPIO 的推挽输出灌电流能力只有 ±25mA 上下而蜂鸣器工作电流通常在 30mA 到 80mA 之间直接驱动不仅拉低引脚电压导致声音发闷长时间还可能损坏 IO。常用做法是用 NPN 三极管做开关参数如下元件参数作用R11kΩ基极限流电阻I_B 约 3.3mAR210kΩ基极下拉防止上电误触发Q1S8050 NPN高电平导通低电平关断D11N4148并联在蜂鸣器两端续流防反向电动势Buzzer5V 有源/无源集电极回路负载寄存器配置上有源和无源完全不同。有源蜂鸣器只需要普通推挽 GPIOvoid Buzzer_Init(void) { GPIO_InitTypeDef g; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); g.GPIO_Pin GPIO_Pin_0; g.GPIO_Mode GPIO_Mode_Out_PP; // 推挽输出 g.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, g); GPIO_ResetBits(GPIOB, GPIO_Pin_0); // 默认关闭 }参数上 GPIO_Speed_50MHz 在这里是 IO 翻转速度上限蜂鸣器开关频率很低用 10MHz 也够统一 50MHz 是为了和工程其他 GPIO 保持一致没什么坏处。无源蜂鸣器则要配置 TIM2 的 PWM 输出PA0 是 TIM2_CH1 的复用引脚必须设置为复用推挽而不是普通推挽这是最容易出错的一步void Buzzer_PWM_Init(void) { GPIO_InitTypeDef g; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); g.GPIO_Pin GPIO_Pin_0; g.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽不是 Out_PP g.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, g); tb.TIM_Prescaler 72 - 1; // 72MHz / 72 1MHz 计数时钟 tb.TIM_CounterMode TIM_CounterMode_Up; tb.TIM_Period 1000; // 1kHz PWM 频率 tb.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, tb); oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_Pulse 500; // 50% 占空比 oc.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, oc); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_Cmd(TIM2, ENABLE); }这里 TIM_Prescaler 等于 71 时计数时钟是 1MHzTIM_Period 设为 1000 得到 1kHz。无源蜂鸣器的谐振点通常在 2kHz 到 4kHz如果声音偏小把 Period 改小到 500 甚至 250 试试占空比保持 50% 左右即可。4.3 间断报警时序设计与自动恢复报警不能设计成长响长响会掩盖后续状态变化长时间运行还容易让操作员听觉疲劳。工程里更实用的设计是“间断报警 窗口恢复”触发报警后蜂鸣器以 200ms 响、200ms 停的节奏工作循环若干次后自动停如果震动状态仍处于报警阈值以上下一个主循环周期会再次进入报警分支。void Alarm_Task(uint8_t burst) { uint8_t i; for (i 0; i burst; i) { GPIO_SetBits(GPIOB, GPIO_Pin_0); // 蜂鸣器响 Delay_ms(200); GPIO_ResetBits(GPIOB, GPIO_Pin_0); // 蜂鸣器停 Delay_ms(200); } } void Alarm_Reset(void) { GPIO_ResetBits(GPIOB, GPIO_Pin_0); // 强制关断蜂鸣器 TIM_Cmd(TIM2, DISABLE); // 若使用 PWM则关闭定时器 }如果板子上是无源蜂鸣器把 GPIO_SetBits 换成 TIM_Cmd(TIM2, ENABLE)GPIO_ResetBits 换成 TIM_Cmd(TIM2, DISABLE)延时逻辑不变。报警恢复逻辑在震动监测里值得多说一句。震动触发报警后立即恢复是常见误报源断电瞬间、测试时手碰传感器都会产生一个短暂脉冲。我一般用一个分钟级计数器做窗口报警后 30 秒内再次检测到超限就把报警计数累加并重新计时超过 30 秒没有再次超限才回到正常状态。这个窗口值按应用场景调室内安防可以短到 5 秒机械监测建议拉长到 60 秒。5. 串口报警数据帧设计与调试助手联调5.1 帧结构设计串口上报的核心不是把数字发出去而是让接收端在任意字节流里都能可靠切分出报警事件。裸发一个 ADC 值没有任何边界接收方分不清是数据还是噪声。工程适合定义固定长度的帧结构帧头、类型、状态、计数、校验。报警帧定为 7 字节大端顺序发送字节偏移内容说明00xAA帧头 110x55帧头 220x01类型0x01 报警帧3状态0x00 正常0x01 报警4计数高字节震动累计次数 uint16 大端5计数低字节6校验前 6 字节累加和的低 8 位0xAA 0x55 双帧头是为了和普通数据区分两个不同字节避免连续相同位导致接收端误同步。校验用累加和低 8 位足够不需要上 CRC16这类短帧在 UART 场景下误码率很低校验的目的是过滤错误帧而不是纠错。5.2 标准库 USART1 的发送实现串口初始化走标准库方式PA9 配置为复用推挽PA10 配置为浮空输入。波特率 1152008 数据位1 停止位无校验这是大多数串口调试助手的默认参数也能避免奇偶校验位带来的帧开销void USART1_Init(void) { GPIO_InitTypeDef g; USART_InitTypeDef u; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); g.GPIO_Pin GPIO_Pin_9; g.GPIO_Mode GPIO_Mode_AF_PP; // TX 复用推挽 g.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, g); g.GPIO_Pin GPIO_Pin_10; g.GPIO_Mode GPIO_Mode_IN_FLOATING; // RX 浮空输入 GPIO_Init(GPIOA, g); u.USART_BaudRate 115200; u.USART_WordLength USART_WordLength_8b; u.USART_StopBits USART_StopBits_1; u.USART_Parity USART_Parity_No; u.USART_Mode USART_Mode_Tx | USART_Mode_Rx; u.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_Init(USART1, u); USART_Cmd(USART1, ENABLE); } void USART_SendFrame(uint8_t state, uint16_t count) { uint8_t i; uint8_t buf[7]; buf[0] 0xAA; buf[1] 0x55; buf[2] 0x01; buf[3] state; buf[4] count 8; // 高字节在前 buf[5] count 0xFF; buf[6] buf[0] buf[1] buf[2] buf[3] buf[4] buf[5]; for (i 0; i 7; i) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); // 等待数据寄存器空 USART_SendData(USART1, buf[i]); } }用 TXE 标志而不是 TC 标志是个细节。TXE 表示数据已经移入移位寄存器、数据寄存器可以写下一个字节适合连续发送TC 表示整个字节包括停止位都送出去了适合发送完成后立刻关闭串口或进入低功耗的场合。如果混用可能在最后一个字节上出现丢帧。5.3 串口调试助手联调与乱码排查收到数据后用 SSCOM、正点原子或 XCOM 这类调试助手打开对应串口把显示模式切到 HEX才能看到 16 进制的 0xAA 0x55 帧头。联调时有几个高频问题按下面的表排查现象最常见原因验证方法全部乱码波特率与实际主频不匹配先发单个字符看是否稳定能发不能收TX/RX 交叉接错交换两根线测试帧对齐不上上电时序导致首字节丢失帧头用双字节并允许跳过偶发丢字节中断优先级配置不当串口中断优先级调到最高乱码问题多说一句。如果代码写的 115200 但接收端全是乱码优先检查主时钟外部晶振 8MHz 的标准配置如果换成了其他频率晶振或者 PLL 倍频配错USART 波特率会系统性偏移。这种错误在 OLED 和蜂鸣器上几乎看不出来只有在串口这种对时钟精度敏感的外设上才会暴露。6. keilkill脚本、工程排错与Flash阈值存储6.1 工程文件与 keilkill.bat这包源码目录里有几个一眼看不出用途的文件。project.uvguix.86187 是 Keil uVision 的窗口布局参数删掉不影响编译project_sct.Bak 是分散加载文件备份改过 Bootloader 的人会用它救回配置project.axf 是链接输出的调试文件。keilkill.bat 就是清理这些编译中间产物的脚本常见内容echo off del /s /q *.o *.d *.crf *.dep *.axf *.htm *.build_log.htm rd /s /q Listings rd /s /q Objects pause编译一次工程会产生几十个 .o 和 .crf 临时文件提交代码前跑一遍 keilkill.bat仓库里就不会混进一堆二进制垃圾。脚本只删编译产物和 Keil 自动生成的 Listings、Objects 目录源码和 .uvprojx 工程文件不受影响。6.2 调试连接与芯片型号配置排错烧录时如果 Keil 报Error: No STM32 target found!先查 ST-Link 四根线 SWDIO、SWCLK、GND、3V3 是否接全再检查 Options for Target - Debug 里选的是不是 ST-Link Debugger。改过引脚复用或进过低功耗模式后连不上把 BOOT0 拉高到 3.3V上电后芯片走系统存储器启动就能重新擦除用户程序。6.3 报警阈值掉电保存到内部 Flash震动报警阈值每次上电都要重新设置实际使用中非常影响体验。更合理的做法是把阈值写到 STM32 内部 Flash 最后一页。工程里的 stm32f10x_flash.c 正好提供这层能力。中容量 STM32F103 的 Flash 页大小为 1KB擦除必须整页执行写入只能按半字进行void Thresh_Save(uint16_t th) { FLASH_Unlock(); // 解锁 Flash 控制器 FLASH_ErasePage(0x0800F800); // 擦除最后一页 FLASH_ProgramHalfWord(0x0800F800, th); // 半字写入阈值 FLASH_Lock(); // 重新上锁 } uint16_t Thresh_Load(void) { return *(volatile uint16_t *)0x0800F800; // 上电后直接读 }写入前必须整页擦除这是 Flash 的物理性质决定的只能把 1 写为 0。0x0800F800 对应 128KB 中容量型号的最后一个页换成 512KB 高容量型号时页大小变 2KB地址要重新计算。上电启动时调用一次 Thresh_Load 读到上次保存的值OLED 上直接显示串口收到特定命令后修改阈值并写回 Flash之后就不用为调一个参数反复烧录整包固件了。本文还有配套的精品资源点击获取