简介:这是一份基于QT与STM32的振动测量系统完整工程资料,定位于本科毕业设计、课程作业及嵌入式初/中级开发者,覆盖从下位机数据采集到上位机界面展示的典型开发环节。压缩包共449个文件,约21.16MB,包含C/C++源码、STM32驱动与启动文件、Keil工程文件、QT界面工程文件、PDF说明文档以及编译调试相关配置等,源码与工程文档一体化,目录结构完整,便于直接打开工程对照学习和二次开发。目前已有113人学习下载,虽然规模不大,但功能链路完整。通过这套资料,可以学习STM32定时器与ADC采样、串口通信、QtSerialPort应用、界面布局与数据实时显示,也能了解振动信号处理、数据记录与GUI集成的方法;硬件侧涉及单片机外设配置与启动流程,软件侧涉及上位机串口收发与图表呈现,从底层驱动到上层交互均有对应文件支撑,适合系统性训练,是一份高实践性的毕设/课设参考素材。
1. 把 QT 上位机与 STM32 采样端拼起来,难点不在单点而在数据链路
拿到“基于QT以及STM32的振动测量系统”这套工程时,第一反应往往是看 STM32 代码能不能烧录、QT 界面能不能起来。真正做完一遍会发现,STM32 侧用标准外设库做定时器触发、ADC 采样、DMA 搬运,QT 侧用 QSerialPort 收包画波形,两边的单点都不难,难的是把采样率、数据帧格式、GUI 刷新率和 FFT 窗口统一成一个链路。这套资源适合正在做课程设计或毕业设计的嵌入式方向学生,也适合快速搭一个低成本振动监测原型机的工程师。下文以 STM32F103 + QT 5 的典型组合为例,从采集、通信、显示到联调,按可复现的步骤拆开讲。
2. 振动采集端:用定时器触发 STM32 ADC,让 DMA 把样本搬走
2.1 为什么不能靠主循环延时采样
很多初版代码习惯在主循环里软件启动 ADC 转换,等待 EOC 标志置位,然后调用 delay_us 固定延时。这种写法在直流采集场景没什么问题,一旦放到 1kHz 以上的振动测量里,缺陷立刻暴露:delay_us 做不到严格等间隔,中断、分支判断都会让下一次采样时刻漂移几十微秒。
对 1kHz 信号来说,50μs 抖动相当于相位的 18 度误差,反映在频谱上就是噪声底抬高,原本应该尖锐的振动谱线变宽,测量系统自身的抖动和振动信号混在一起。正确的做法是让 TIM 定时器产生固定频率事件作为 ADC 外部触发源,ADC 转换完毕由 DMA 自动搬运到内存,CPU 只做打包和发送。
2.2 采样率、ADC 采样时间与抗混叠匹配
振动测量目标信号通常集中在几十 Hz 到几 kHz,F103 的 12 位 ADC 完全够用。系统采样率建议取 2kHz 到 5kHz。采样率低于 2kHz 会漏掉高频特征,高于 5kHz 则串口带宽和上位机绘制压力同时上涨。采样率 fS 由定时器频率除以 ARR+1 决定;ADC 时钟来自 APB2,最高不能超过 14MHz。
外部模拟加速度计的带宽通常只有几 kHz,若传感器本身带宽超过 fS/2,必须在 ADC 前加一级 RC 低通。以 10kΩ 电阻配 10nF 电容估算,截止频率约 1.6kHz,适合 2kHz 采样的系统。采样时间也要注意,F103 支持从 1.5 到 239.5 周期的采样时间档,档位越高输入等效阻抗要求越低,噪声抑制越好;振动测量推荐 55.5 周期或 239.5 周期,不要为了追求速率选最低档。
2.3 定时器触发 + ADC 外部触发 + DMA 循环的配置
下面的代码基于 STM32 标准外设库,压缩包里的 stm32f10x_tim.c 印证了这套工程是在 Keil MDK + SPL 环境下维护的。TIM3 的更新事件作为 TRGO 输出,直接连到 ADC1 的外部触发输入;ADC1 转换结果由 DMA1 通道 1 循环写入内存缓冲区,CPU 完全不参与采样循环。
#define FS 2000 #define SAMPLES 256 static uint16_t adc_buf[SAMPLES]; void TIM3_Init(void) { TIM_TimeBaseInitTypeDef tim; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); tim.TIM_Prescaler = 72 - 1; tim.TIM_Period = 1000000 / FS - 1; tim.TIM_CounterMode = TIM_CounterMode_Up; tim.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &tim); TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update); TIM_Cmd(TIM3, ENABLE); } void ADC1_DMA_Init(void) { GPIO_InitTypeDef gpio; ADC_InitTypeDef adc; DMA_InitTypeDef dma; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin = GPIO_Pin_0; gpio.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &gpio); dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)adc_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = SAMPLES; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; dma.DMA_Priority = DMA_Priority_High; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &dma); DMA_ITConfig(DMA1_IT_TC1 | DMA1_IT_HT1, ENABLE); adc.ADC_Mode = ADC_Mode_Independent; adc.ADC_ScanConvMode = DISABLE; adc.ADC_ContinuousConvMode = DISABLE; adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO; adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); }关键在DMA_Mode_Circular:缓冲区写满后自动回绕,采样不中断;DMA_ITConfig同时打开半传输和全传输中断,正好把 256 个点分成两个 128 点的块。ADC_ExternalTrigConvCmd必须调用,它使 ADC 真正进入外部触发等待状态,漏掉这一行,ADC 永远不启动。定时器预分频 72-1 把 72MHz 降到 1MHz,Period 设成 1000000/FS-1,所以修改 FS 宏就能调整采样率,不需要动定时器结构体。ADC_SampleTime_239Cycles5是档位名,代表 239.5 个 ADC 时钟周期,配合 12MHz ADC 时钟约 21μs 完成一次转换,2kHz 采样率留有余量。
2.4 中断里只做搬运,不做计算
半传输和全传输中断是采样链路的节拍器。每次进入中断,DMA 已经写完了对应半段缓冲区,直接调用 processBlock 处理 128 个点;处理完要立即清标志,否则会重复进入同一事件。
void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT1)) { DMA_ClearITPendingBit(DMA1_IT_HT1); processBlock(0, SAMPLES / 2 - 1); } if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); processBlock(SAMPLES / 2, SAMPLES - 1); } }processBlock 内部做原始值到电压的换算、按传感器灵敏度转成加速度,再调用串口发送。不要在中断里做浮点 FFT;F103 没有 FPU,一次 256 点复数蝶形运算要消耗接近毫秒,已经超过下一次 DMA 中断间隔,会直接破坏采样连续性。频谱分析放到上位机,这也是把原始 ADC 值原样送上去的原因。
提示:采样率、缓冲区大小和串口发送频率三个参数是绑定的。2000/256 约每秒 7.8 帧,每帧 520 字节左右,460800 波特率下完全不紧张;如果把采样率调到 10kHz,就得缩小 SAMPLES 或提高波特率,否则串口会成为瓶颈。
3. 串口数据链路:设计一份容错的二进制帧协议
3.1 为什么必须加帧头、长度和校验
振动数据是连续流,STM32 每次发一包,QT 端用 QSerialPort readAll 读回来的数据不会自动按帧切分。一次 readAll 可能同时包含上一帧的尾、这一帧的全体和下一帧的头。如果直接按长度截断,偶尔一字节丢失就会导致整条波形错位。
给数据加帧头、长度和校验是防御这种错位的标准做法。帧头告诉解析器从哪里开始,长度告诉解析器一帧占多少字节,校验让损坏帧不会进入绘图缓冲区。这个思路不只在 QT 上位机里适用,任何 MCU 与 PC 的串口通信都建议按这个结构设计。
3.2 帧格式定义与波特率计算
协议帧总共 4 + len + 1 字节,len 是数据区字节数。
| 偏移 | 字段 | 说明 |
|---|---|---|
| 0 | 0xAA | 帧头 1 |
| 1 | 0x55 | 帧头 2 |
| 2 | len | 数据区字节数 |
| 3 | type | 0x01 时域数据,0x02 数字传感器数据 |
| 4..4+len-1 | data | uint16 little-endian,原始 ADC 值 |
| 4+len | sum | 第 0 到 4+len-1 字节累加和的低 8 位 |
波特率选择以整帧发送时间不超过采样周期为原则。每秒 7.8 帧,每帧 520 字节,一个字节 10 bit,波特率 460800 时发送一帧约 11.3ms,而一帧对应的采样时间为 128ms,串口占用很低。实际工程里我一般把波特率固定在 460800 或 921600,配合 2kHz 采样率都能满足。
3.3 STM32 端的打包发送实现
发送函数把 processBlock 计算出来的样本数组拼成一帧。示例代码是阻塞发送,优点是帧不会被拆散,缺点是发送期间如果发生 DMA 半满中断,响应会稍微延后;对 460800 波特率和 520 字节的帧来说,阻塞时间约 11ms,中断延迟在可接受范围,课程设计阶段先跑通这个版本比引入发送中断更好理解。
static void send_frame(uint8_t type, const uint16_t *data, int n) { uint8_t frame[520]; uint8_t sum = 0; int len = n * 2; int i; frame[0] = 0xAA; frame[1] = 0x55; frame[2] = len; frame[3] = type; for (i = 0; i < n; i++) { frame[4 + i * 2] = data[i] & 0xFF; frame[5 + i * 2] = data[i] >> 8; } for (i = 0; i < 4 + len; i++) sum += frame[i]; frame[4 + len] = sum; for (i = 0; i < 4 + len + 1; i++) { while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); USART_SendData(USART2, frame[i]); } }数据区低字节在前,与 x86 小端一致,QT 端直接按小端拼接即可。校验累加从帧头开始到数据区最后一个字节,结果再对 256 取模,8 位校验能拦截绝大多数随机误码;如果现场电磁干扰很强,可以把累加和升级成 CRC8。
3.4 QT 端解析:缓冲区剥帧而不是一次性截取
上位机最常见的错误是在 onReadyRead 里按当前可读字节数直接解析,第二次读取时帧就乱了。正确做法是维护一个成员缓冲区,收到数据先 append,再用循环把完整帧逐帧剥出来。下面代码放在 MainWindow 的串口槽函数里。
void MainWindow::onReadyRead() { m_buffer.append(serial->readAll()); while (true) { int head = m_buffer.indexOf(QByteArray::fromHex("AA55")); if (head < 0) { m_buffer = m_buffer.right(2); return; } if (head > 0) m_buffer.remove(0, head); if (m_buffer.size() < 4) return; quint8 len = m_buffer[2]; if (len > 512) { m_buffer.clear(); return; } int total = 4 + len + 1; if (m_buffer.size() < total) return; quint8 sum = 0; for (int i = 0; i < 4 + len; ++i) sum += m_buffer[i]; if (sum == m_buffer[4 + len]) { parseData((const quint8 *)m_buffer.constData() + 4, len / 2); } m_buffer.remove(0, total); } }indexOf 搜索帧头,找不到时保留最后两个字节是为了处理帧头恰好被串口分两次读到的边界情况。校验通过后,parseData 里把每两个字节拼成 uint16:val = buf[i*2] | buf[i*2+1] << 8,再代入传感器换算公式得到物理量。len 上限 512 字节对应 256 个样本,超过 512 说明同步丢失,直接清空缓冲区重新等帧头。
4. 上位机可视化:QCustomPlot 实时波形与自写 FFT 频谱
4.1 实时绘图的刷新节奏
QCustomPlot 是 QT 生态里常用的图线绘制库,性能比 QWidget 手动重绘好。振动测量每秒产生几千个样本,如果每收到一帧都调用 replot,界面会卡到帧率只有个位数。更稳的节奏是用一个 30ms 定时器聚合刷新,绘图数据先存到滚动数组,定时器到点后一次性更新两个 Plot:一个时域,一个频域。
void MainWindow::onPlotTimer() { ui->plotTime->graph(0)->setData(timeAxis, wave); ui->plotFreq->graph(0)->setData(freqAxis, mag); ui->plotTime->xAxis->setRange(timeAxis.first(), timeAxis.last()); ui->plotTime->yAxis->rescale(); ui->plotFreq->yAxis->rescale(); ui->plotTime->replot(); ui->plotFreq->replot(); }这段代码中 setData 直接整体替换曲线数据,比逐点 addData 效率高几个量级。xAxis->setRange 固定到当前窗口,实现滚动效果;rescale 让 y 轴自动跟随幅值变化,观察振动趋势非常方便。注意 replot 只能在主线程调用,如果串口解析放在 QThread 里,必须 emit 信号回到主线程槽函数。
4.2 256 点滑动 FFT:频谱泄漏处理
振动信号的频率成分比时域波形更有诊断价值。取最近一帧 256 点做 FFT,采样率 2kHz,频率分辨率约为 7.8Hz。FFT 对整周期截断非常敏感,直接对矩形窗数据做变换,非整数倍频率会泄漏成一片背景噪声,所以要先乘汉宁窗。自写基2蝶形 FFT 的幅值计算代码比较简洁:
static QVector<double> computeMagnitude(const QVector<double> &samples) { const int n = samples.size(); QVector<double> re(n), im(n, 0.0); for (int i = 0; i < n; ++i) re[i] = samples[i] * 0.5 * (1.0 - qCos(2.0 * M_PI * i / (n - 1))); fft(re.data(), im.data(), n, false); QVector<double> mag(n / 2); for (int i = 0; i < n / 2; ++i) mag[i] = 2.0 * qSqrt(re[i] * re[i] + im[i] * im[i]) / n; return mag; } static void fft(qreal *re, qreal *im, int n, bool inverse) { for (int i = 1, j = 0; i < n; ++i) { int bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) qSwap(re[i], re[j]), qSwap(im[i], im[j]); } for (int len = 2; len <= n; len <<= 1) { qreal ang = (inverse ? 2.0 : -2.0) * M_PI / len; qreal wRe = qCos(ang), wIm = qSin(ang); for (int i = 0; i < n; i += len) { qreal curRe = 1.0, curIm = 0.0; for (int j = 0; j < len / 2; ++j) { qreal uRe = re[i+j], uIm = im[i+j]; qreal vRe = re[i+j+len/2]*curRe - im[i+j+len/2]*curIm; qreal vIm = re[i+j+len/2]*curIm + im[i+j+len/2]*curRe; re[i+j] = uRe + vRe; im[i+j] = uIm + vIm; re[i+j+len/2] = uRe - vRe; im[i+j+len/2] = uIm - vIm; qreal tmp = curRe * wRe - curIm * wIm; curIm = curRe * wIm + curIm * wRe; curRe = tmp; } } } if (inverse) for (int i = 0; i < n; ++i) { re[i] /= n; im[i] /= n; } }汉宁窗会压低幅值,所以最后除 n 再乘 2 做幅度恢复。fft 函数是在位蝶形实现,要求 n 为 2 的整数幂。samples 的长度必须与下位机 SAMPLES 一致,否则频谱横轴对应的最高频率会偏移。如果发现 50Hz 工频附近出现明显泄漏带,多半是窗口没加成功或采样率并非严格 2kHz。
4.3 原始 ADC 值到加速度物理量的换算
串口上传的是原始 ADC 值,物理量换算放上位机,这样更换传感器或灵敏度参数不用重新刷 STM32。常见的 F103 模拟量读取场景,ADC 输入电压 = raw * 3.3V / 4096。下表列出三类传感器的典型换算参数。
| 传感器 | 灵敏度 | 零点 | 加速度公式 |
|---|---|---|---|
| 模拟加速度计 ±1.6g | 57mV/g | 1.5V | (V - 1.5) / 0.057 |
| ADXL345 4g 模式 | 15.6 LSB/g | 0 | raw / 15.6 |
| MPU6050 2g 模式 | 16384 LSB/g | 0 | raw / 16384 |
如果采用数字加速度计,STM32 通过 I2C/SPI 读取寄存器,串口帧 type 字段改成 0x02,数据区直接放传感器原始寄存器值,上位机按对应灵敏度换算即可。时域图 y 轴单位显示 g,频谱图建议用线性幅值 g,或 20log10 转 dB,两种都可以,但一个系统内要保持一致,否则对比数据时容易误判振动强度。
5. 联调排错与验证技巧:从一串毛刺定位故障路段
5.1 最常踩的几个联调点
联调阶段的现象往往指向多个可能原因。串口完全没数据,可能线接反,也可能 DMA 中断没启动;波形出现周期性毛刺,可能是电源纹波,也可能上位机解析帧错位。下面是按概率排序的检查顺序。
| 现象 | 检查顺序 |
|---|---|
| 串口无数据 | USB 转串口 RXD 是否接 STM32 TX,地线是否同地 |
| 输出乱码 | 波特率是否一致,RCC 是否 72MHz |
| 波形毛刺多 | ADC 采样时间太低,传感器电源是否干净,RC 滤波是否漏焊 |
| GUI 卡死 | 是否在串口槽函数里做了耗时解析,是否每次数据都 replot |
压缩包里的 QSerialPort_github.pro.user 和 Project.axf 容易让人误以为工程已经完整,实际跑起来发现串口没反应时,先检查串口号和波特率是否和代码一致,再用串口助手直接看 MCU 发出来的字节,能少走一半弯路。
5.2 用一节电池做完整的信号链验证
没有信号发生器时,可以用干电池加 10kΩ 电阻手动制造方波:电阻一端接电池正极,另一端接 ADC 输入,杜邦线反复触碰电池正极或断开,产生 1Hz 级随机方波。看到上位机时域图出现明显的两段电平,说明采集、串口组帧、QT 解析三级链路全部打通。再打开频谱图,如果主峰出现在频率接近触碰频率的低频段,而其他频段噪声底低于主峰 20dB 以上,链路就没有引入额外强干扰。
这个测试的巧妙之处在于它一次性验证了时域响应、FFT 功能、校验丢弃机制和坐标轴单位。如果触碰时频谱 50Hz 处跟着亮起来,说明工频已经通过参考地侵入 ADC,需要在传感器供电和模拟地布局上找原因。更严谨的做法是调整采样率到 2048Hz、缓冲区到 1024 点,得到 2Hz 的频率分辨率,再进行整周期正弦信号的定量标定。
本文还有配套的精品资源,点击获取