简介:本资源是一套基于STM32单片机实现的高实用性嵌入式信号采集与分析系统,面向电子类专业本科生、嵌入式初学者及课程设计/毕设实践者,解决模拟信号实时采集、频域分析与音频异常检测等典型工程问题。项目完整复现2014年TI杯电子设计竞赛D题——带啸叫检测与抑制的音频功率放大器,涵盖ADC电压电流同步采样、LCD实时示波显示、FFT频谱生成、啸叫识别及数字陷波器抑制等核心功能模块。压缩包含256个文件,以78个C源码和90个头文件(.c/.h)构成主程序逻辑,辅以40个汇编启动文件(.s)、27个Flash配置文件(.fcf)及Keil工程文件(.uvprojx/.uvoptx),整体仅1.15MB,轻量易部署。已有420人学习下载,提供可直接编译运行的完整工程、清晰的模块化代码结构、配套hex固件及Python数据处理脚本,助力读者深入理解嵌入式信号处理全流程实现。
1. 为什么用 STM32 做电压电流采集+LCD 示波,比“只看数字”强一个数量级?
你手头有一块 STM32F103C8T6 最小系统板,接了两个分流电阻和运放调理电路,能测 0–30V 电压和 0–5A 电流——但串口打印出来的只是V: 12.47V, I: 1.83A这样的静态数值。问题来了:当电机启停、LED 灯带突变、电源纹波干扰出现时,这些平均值完全掩盖了毫秒级的尖峰、跌落与振荡。而真正需要诊断的,恰恰是这些瞬态行为。本项目不是简单读 ADC 再写 LCD,而是把 STM32 变成一台嵌入式示波器:用片内 ADC 高速采样(≥100kS/s),DMA 自动搬运数据,FSMC 或 SPI 驱动 40pin LCD 屏(如 ILI9341),在 320×240 像素上实时绘制双通道波形(电压+电流),支持触发、缩放、冻结、坐标网格——所有逻辑运行在裸机或 FreeRTOS 下,不依赖 PC。适合电源调试、电机驱动验证、电池充放电监测等需现场波形观察的工业与教学场景。新手可从单通道电压波形起步,熟手可扩展至四通道同步采样+FFT 频谱分析。
2. ADC 采样链路设计:从信号调理到 DMA 搬运,每一步都决定波形保真度
2.1 为什么必须做信号调理?直接接 ADC 会丢掉关键细节
STM32F103 的 ADC 输入范围是 0–3.3V,但实际电压电流信号常超出此限(如 24V 电源、5A 电流经 0.01Ω 分流电阻产生 50mV)。若直接接入,轻则饱和失真,重则损坏 IO。常见做法是采用两级调理:
- 电压通道:分压电阻网络(如 100kΩ + 15kΩ)将 0–30V 缩至 0–3.91V,再经 LM358 跟随器隔离,并用 TVS 二极管(如 P6KE3.3A)钳位至 3.3V;
- 电流通道:0.01Ω 分流电阻 + INA199 电流检测放大器(增益 50V/V),输出 0–2.5V 对应 0–5A,同样加 RC 低通滤波(R=1kΩ, C=10nF → fc≈15.9kHz)抑制开关噪声。
提示:分压电阻需选用 0.1% 精度、低温漂(<50ppm/℃)型号,否则 30V 量程下 0.5% 误差即达 ±150mV,远超示波需求。
2.2 ADC 配置核心参数:采样周期、分辨率与触发方式
以 STM32F103 为例,ADC1 用于电压,ADC2 用于电流(双 ADC 模式可同步采样)。关键配置如下表:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 分辨率 | 12-bit | F103 最高支持,8-bit 波形太粗糙(256 级),12-bit 提供 4096 级量化,满足 30V 量程下 ≈7.3mV 分辨率 |
| 采样周期 | 1.5 cycles(最小) | 对应 14MHz ADCCLK 下,单次采样时间 ≈107ns;实际总采样率受转换时间限制,12-bit 典型为 1μs/次 → 理论最大 1MS/s,但受 DMA 和 LCD 刷新制约,实测稳定 100kS/s |
| 触发源 | 定时器 TRGO(TIM2) | 避免软件触发抖动,TIM2 设置为 100kHz 更新事件(ARR=71, PSC=0 @ 72MHz),精准控制采样间隔 |
| 对齐方式 | 右对齐 | 数据直接存入 uint16_t 数组,无需位移操作 |
// 初始化 ADC1(电压通道 PA0)与 ADC2(电流通道 PA1) void ADC_Init(void) { ADC_InitTypeDef ADC_InitStructure; ADC_CommonInitTypeDef ADC_CommonInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_ADC1 | RCC_APB2PERIPH_ADC2, ENABLE); // 双 ADC 模式:ADC1 主,ADC2 从,同步规则转换 ADC_CommonInitStructure.ADC_Mode = ADC_Mode_RegSimult; // 同步规则采样 ADC_CommonInitStructure.ADC_Prescaler = ADC_Prescaler_Div2; // 72MHz/2=36MHz ADCCLK ADC_CommonInitStructure.ADC_DMAAccessMode = ADC_DMAAccessMode_1; // 每次转换后 DMA 请求 ADC_CommonInitStructure.ADC_TwoSamplingDelay = ADC_TwoSamplingDelay_5Cycles; ADC_CommonInit(&ADC_CommonInitStructure); ADC_InitStructure.ADC_Resolution = ADC_Resolution_12b; ADC_InitStructure.ADC_ScanConvMode = DISABLE; // 单通道,非扫描 ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; // 单次转换,由 TIM2 触发 ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; // TIM2 更新事件 ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfConversion = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_Init(ADC2, &ADC_InitStructure); ADC_Cmd(ADC1, ENABLE); ADC_Cmd(ADC2, ENABLE); }2.2.1 为什么禁用连续转换模式?
连续模式下 ADC 自动重复采样,但无法精确控制采样点间隔(受内部状态机影响),导致波形横轴时间刻度失真。使用定时器触发,每个采样点严格对应 TIM2 的更新事件,保证时间轴线性——这是示波功能成立的前提。
2.3 DMA 配置:零 CPU 干预搬运双通道数据
ADC1 和 ADC2 共享一个 DMA 通道(DMA1 Channel1),需配置为双缓冲模式,交替存储电压与电流数据:
// DMA 初始化:传输 2048 个 uint16_t(1024 点 × 2 通道) void DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPERIPH_DMA1, ENABLE); DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; // ADC1 数据寄存器地址 DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_buffer; // 内存起始地址 DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = 2048; // 总字数 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环模式,持续采集 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &DMA_InitStructure); // 使能 DMA 传输完成中断(用于波形刷新) DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE); NVIC_EnableIRQ(DMA1_Channel1_IRQn); DMA_Cmd(DMA1_Channel1, ENABLE); }注意:
&ADC1->DR是 ADC1 的数据寄存器地址,但双 ADC 模式下,DMA 实际从 ADC1->DR 读取的是 ADC1 值,从 ADC2->DR 读取的是 ADC2 值(硬件自动切换)。因此adc_buffer中数据按[V0, I0, V1, I1, ..., V1023, I1023]顺序排列,无需额外索引计算。
3. LCD 驱动与波形绘制:从 40pin 接口初始化到实时刷新优化
3.1 40pin LCD 接口选型与硬件连接要点
本项目采用 ILI9341 驱动的 3.2" TFT 屏(典型 40pin 接口),其并行接口(8/16bit)速度远高于 SPI,适合波形高频刷新。关键引脚连接如下(以 STM32F103C8T6 为例):
| LCD 引脚 | STM32 引脚 | 功能说明 |
|---|---|---|
| D0–D7 | PB0–PB7 | 8-bit 数据总线(复用 GPIOB) |
| RS (DC) | PB8 | 寄存器/数据选择 |
| WR (RD) | PB9 | 写使能(低电平有效) |
| CS | PB10 | 片选(低电平有效) |
| RST | PB11 | 复位(高电平有效) |
| BL (背光) | PA8 (PWM) | 通过 TIM1 CH1 控制亮度 |
提示:40pin 接口通常含 VCC、GND、LED+、LED- 等电源引脚,务必确认背光电压(多数为 3.3V 或 5V),避免烧毁 LED。若使用 5V 背光,需外接 LDO 或 MOSFET 开关控制。
3.2 FSMC 配置:让 LCD 像内存一样被快速访问
ILI9341 在 FSMC 模式下映射为 NOR Flash,地址空间为0x60000000。配置 FSMC_Bank1_NORSRAMx 为 8-bit 数据总线、异步模式:
void FSMC_LCD_Init(void) { FSMC_NORSRAMInitTypeDef FSMC_NORSRAMInitStructure; FSMC_NORSRAMTimingInitTypeDef pTiming; RCC_AHBPeriphClockCmd(RCC_AHBPERIPH_FSMC, ENABLE); // 地址建立时间:1 个 HCLK 周期(13.89ns @ 72MHz) pTiming.FSMC_AddressSetupTime = 1; pTiming.FSMC_AddressHoldTime = 0; pTiming.FSMC_DataSetupTime = 3; // 数据保持时间 ≥25ns,设为 3→41.67ns pTiming.FSMC_BusTurnAroundDuration = 0; pTiming.FSMC_CLKDivision = 0; pTiming.FSMC_DataLatency = 0; FSMC_NORSRAMInitStructure.FSMC_Bank = FSMC_Bank1_NORSRAM1; FSMC_NORSRAMInitStructure.FSMC_DataAddressMux = FSMC_DataAddressMux_Disable; FSMC_NORSRAMInitStructure.FSMC_MemoryType = FSMC_MemoryType_SRAM; FSMC_NORSRAMInitStructure.FSMC_MemoryDataWidth = FSMC_MemoryDataWidth_8b; FSMC_NORSRAMInitStructure.FSMC_BurstAccessMode = FSMC_BurstAccessMode_Disable; FSMC_NORSRAMInitStructure.FSMC_WaitSignalPolarity = FSMC_WaitSignalPolarity_Low; FSMC_NORSRAMInitStructure.FSMC_AsynchronousWait = FSMC_AsynchronousWait_Disable; FSMC_NORSRAMInitStructure.FSMC_WrapMode = FSMC_WrapMode_Disable; FSMC_NORSRAMInitStructure.FSMC_WaitSignalActive = FSMC_WaitSignalActive_BeforeWaitState; FSMC_NORSRAMInitStructure.FSMC_WriteOperation = FSMC_WriteOperation_Enable; FSMC_NORSRAMInitStructure.FSMC_WaitSignal = FSMC_WaitSignal_Disable; FSMC_NORSRAMInitStructure.FSMC_ExtendedMode = FSMC_ExtendedMode_Disable; FSMC_NORSRAMInitStructure.FSMC_WriteBurst = FSMC_WriteBurst_Disable; FSMC_NORSRAMInitStructure.FSMC_ReadWriteTimingStruct = &pTiming; FSMC_NORSRAMInitStructure.FSMC_WriteTimingStruct = &pTiming; FSMC_NORSRAMInit(&FSMC_NORSRAMInitStructure); FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM1, ENABLE); }3.2.1 为什么不用 SPI?
SPI 最高理论速率约 18MHz(F103 SPI1 APB2),但实际写像素需发送命令+参数+数据,单像素耗时 >1μs;而 FSMC 并行写入单像素仅需 2 个总线周期(≈27.7ns),快 30 倍以上。1024 点波形刷新,FSMC 可在 3ms 内完成,SPI 则需 >100ms,无法满足实时性。
3.3 波形绘制算法:双缓冲+增量更新,CPU 占用率 <15%
全屏重绘(320×240=76800 像素)会阻塞 ADC 采样。采用双缓冲策略:
frame_buffer[2][320]:两个 320 像素宽的行缓冲区,存储当前波形 Y 坐标(0–239);lcd_buffer[2][320*240]:两帧完整屏幕缓冲,后台 DMA 刷新;- 每次 DMA 传输完成中断中,仅计算新采样点对应的 1 像素列(X 固定,Y 根据 ADC 值映射),并更新
frame_buffer; - 主循环中,当
frame_buffer满 320 点,调用LCD_DrawWave()将整行波形写入lcd_buffer,再触发 FSMC 刷新。
// 将 ADC 值映射为屏幕 Y 坐标(0=顶部,239=底部) #define VOLTAGE_SCALE (239.0f / 4095.0f * 30.0f) // 30V 量程 → 0–239 #define CURRENT_SCALE (239.0f / 4095.0f * 5.0f) // 5A 量程 → 0–239 void UpdateWavePoint(uint16_t v_adc, uint16_t i_adc, uint16_t x_pos) { static uint16_t frame_idx = 0; int16_t v_y = 239 - (int16_t)(v_adc * VOLTAGE_SCALE / 30.0f); // 电压:顶部为 0V int16_t i_y = 239 - (int16_t)(i_adc * CURRENT_SCALE / 5.0f); // 电流:顶部为 0A v_y = CLAMP(v_y, 0, 239); i_y = CLAMP(i_y, 0, 239); // 绘制电压线(红色)与电流线(蓝色)在同一 X 列 lcd_buffer[active_buffer][x_pos + v_y * 320] = RED; lcd_buffer[active_buffer][x_pos + i_y * 320] = BLUE; if (++frame_idx >= 320) { frame_idx = 0; // 切换缓冲区并刷新 active_buffer ^= 1; LCD_FillScreen(lcd_buffer[active_buffer ^ 1]); // FSMC DMA 刷新 } }注意:
CLAMP()宏防止 ADC 异常值(如短路)导致 Y 坐标越界。实际部署中,建议在 ADC 初始化后添加校准步骤:短接输入端读取零点偏移,再测已知电压修正增益。
4. 触发与交互逻辑:实现类似台式示波器的核心功能
4.1 边沿触发机制:捕获瞬态事件的关键
无触发的滚动波形无法稳定观察脉冲。本项目实现软件边沿触发,基于最近 N 个采样点判断:
#define TRIGGER_DEPTH 64 // 触发前保留 64 点 typedef enum { TRIG_OFF, TRIG_RISING, TRIG_FALLING } trigger_mode_t; trigger_mode_t trigger_mode = TRIG_RISING; uint16_t trigger_level = 2048; // ADC 中值(1.65V) uint8_t trigger_state = 0; // 0=等待,1=已触发 void CheckTrigger(uint16_t v_adc) { static uint16_t history[TRIGGER_DEPTH]; static uint8_t hist_idx = 0; // 移入新采样点 history[hist_idx] = v_adc; hist_idx = (hist_idx + 1) % TRIGGER_DEPTH; if (trigger_state == 0) { // 检查上升沿:前一点 < level,当前点 ≥ level uint16_t prev = history[(hist_idx - 2 + TRIGGER_DEPTH) % TRIGGER_DEPTH]; if (trigger_mode == TRIG_RISING && prev < trigger_level && v_adc >= trigger_level) { trigger_state = 1; // 从 history[(hist_idx - TRIGGER_DEPTH) % TRIGGER_DEPTH] 开始显示 } // 下降沿类似... } }4.1.1 触发位置如何影响波形显示?
触发后,frame_buffer不再按自然顺序填充,而是从触发点开始,先填TRIGGER_DEPTH个历史点(从history[]回溯),再填后续实时点。这样屏幕上显示的是“触发前 64 点 + 触发后 256 点”,符合示波器习惯。
4.2 LCD 屏显示中文:用字模提取工具生成 GB2312 点阵
用户需在 LCD 上显示“电压:”、“电流:”、“触发:ON”等中文标签。常见做法是使用 PCtoLCD2002 工具,将 GB2312 字库提取为 16×16 点阵数组,存入 Flash:
// 示例:'电' 字的 16×16 点阵(每行 2 字节,共 16 行) const uint8_t font16x16_zh[] = { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // ... 256 字节省略 }; void LCD_DisplayChinese(uint16_t x, uint16_t y, const uint8_t *font_data, uint16_t color) { for (uint8_t row = 0; row < 16; row++) { for (uint8_t col = 0; col < 16; col++) { uint8_t bit = (font_data[row * 2 + (col / 8)] & (0x80 >> (col % 8))) ? 1 : 0; if (bit) LCD_SetPoint(x + col, y + row, color); } } }提示:40pin LCD 接口带宽充足,可支持 16×16 中文,但需预留 Flash 空间(GB2312 常用字约 3000 个,每个 32 字节 → 96KB)。若空间紧张,可只提取项目所需汉字(如“电、压、流、触、发、ON、OFF”等 20 字)。
4.3 旋钮与按键交互:调节触发阈值与时间基准
硬件上接入一个 10kΩ 电位器(接 ADC3_IN0)和两个轻触按键(PA12、PA13):
- 电位器:映射为触发阈值(0–30V → ADC 值 0–4095),实时更新
trigger_level; - KEY1(PA12):短按切换触发模式(OFF → RISING → FALLING),长按(>1s)进入校准模式;
- KEY2(PA13):短按切换时基(1ms/div → 5ms/div → 10ms/div),改变 TIM2 的 ARR 值。
// 按键扫描(主循环中调用) void KEY_Scan(void) { static uint32_t key1_down_time = 0; static uint8_t key1_pressed = 0; if (!GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_12)) { if (!key1_pressed) { key1_pressed = 1; key1_down_time = HAL_GetTick(); } if (HAL_GetTick() - key1_down_time > 1000) { EnterCalibration(); // 长按进入校准 } } else { if (key1_pressed) { if (HAL_GetTick() - key1_down_time < 500) { CycleTriggerMode(); // 短按切换模式 } key1_pressed = 0; } } }5. 抗干扰与精度优化:解决 ADC 数据漂移与 LCD 亮度波动的实际技巧
5.1 ADC 数据漂移的三大根源与对应措施
实测中常出现“静止信号下 ADC 值跳动 ±5 LSB”,这会导致波形基线抖动。根本原因及对策如下:
| 漂移来源 | 现象 | 解决方案 |
|---|---|---|
| 电源噪声 | 所有通道同步跳动 | 在 VREF+ 引脚就近加 100nF 陶瓷电容 + 10μF 钽电容;ADC 供电用独立 LDO(如 AMS1117-3.3)而非 MCU 的 3.3V |
| 参考电压波动 | VREF+ 未接稳压源,随负载变化 | 必须使用内部 VREFINT 校准或外部精密基准(如 REF3033);禁用 VDDA 作为参考 |
| PCB 布局干扰 | 高频信号线靠近 ADC 走线 | ADC 输入走线远离晶振、SWD、电机驱动线;铺地平面隔离;输入端加 RC 滤波(R=100Ω, C=100nF → fc=15.9kHz) |
注意:
ADC_GetCalibrationValue()在初始化后必须调用一次,否则内部校准未生效。F103 的校准值存储在 Flash 中,每次上电需重新加载。
5.2 LCD 亮度动态调节:用 PWM 避免闪烁与功耗浪费
背光亮度直接影响波形可视性。直接用 GPIO 控制 LED 会因占空比低而闪烁,必须用 PWM:
// TIM1 CH1 输出 PWM 控制背光(PA8) void PWM_Backlight_Init(uint16_t duty_cycle) { // duty_cycle: 0–1000(0.1% 精度) TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_TIM1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 999; // 1kHz PWM(72MHz / (999+1) = 72kHz → /72 = 1kHz) TIM_TimeBaseStructure.TIM_Prescaler = 71; // PSC=71 → 72MHz/(71+1)=1MHz 计数频率 TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = duty_cycle; // 初始占空比 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); }5.2.1 为什么用 TIM1 而非通用定时器?
TIM1 是高级定时器,支持互补输出与死区插入,虽本项目未用,但其 PWM 频率稳定性优于 TIM2/TIM3(后者为通用定时器,时钟源易受 APB1 分频影响)。1kHz PWM 频率远高于人眼临界融合频率(60Hz),彻底消除可见闪烁。
5.3 一个关键技巧:用 ADC 的注入通道做零点校准
每次开机时,自动短接电压/电流输入端(通过继电器或模拟开关),读取 64 次 ADC 值取平均,作为零点偏移v_offset和i_offset。后续所有采样值减去该偏移:
uint16_t v_offset = 0, i_offset = 0; void CalibrateZero(void) { uint32_t sum_v = 0, sum_i = 0; // 短接输入端(硬件动作) HAL_GPIO_WritePin(GPIOB, GPIO_Pin_12, GPIO_PIN_SET); // 控制继电器闭合 for (int i = 0; i < 64; i++) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); sum_v += ADC_GetConversionValue(ADC1); ADC_SoftwareStartConvCmd(ADC2, ENABLE); while (!ADC_GetFlagStatus(ADC2, ADC_FLAG_EOC)); sum_i += ADC_GetConversionValue(ADC2); Delay_us(100); // 避免采样过快 } v_offset = sum_v / 64; i_offset = sum_i / 64; // 断开短接 HAL_GPIO_WritePin(GPIOB, GPIO_Pin_12, GPIO_PIN_RESET); }此技巧可将零点漂移从 ±10 LSB 降至 ±1 LSB,使 30V 量程下的绝对误差从 ±300mV 改善至 ±30mV,满足大多数工业监测需求。
本文还有配套的精品资源,点击获取