便携式示波器实战:STM32 + TFT 显示,口袋里的测量神器
作为一名嵌入式工程师,示波器绝对是使用频率最高的工具之一。但台式示波器动辄几千上万,还笨重得很。今天我们就来 DIY 一台便携式示波器,用 STM32 做主控,配上 TFT 彩屏,成本不到 200 块,却能应付日常 80% 的测量需求。
这个项目源于我的一次踩坑经历:有次在客户现场调试电路,发现信号有问题,但没带示波器。等借到设备时,问题又复现不了了。从那以后,我就决心搞个能塞进工具包的小示波器。
需要准备什么?
| 物品 | 型号/规格 | 价格 |
|---|---|---|
| 主控板 | STM32F407VET6 (168MHz) | ¥45 |
| 显示屏 | 3.5 寸 TFT LCD (320×480) | ¥35 |
| ADC 模块 | 外部 12 位 ADC (可选) | ¥25 |
| 衰减电路 | 电阻分压网络 + 运放 | ¥15 |
| 电池 | 18650 锂电池 2000mAh | ¥20 |
| 充电模块 | TP4056 充电板 | ¥5 |
| 外壳 | 3D 打印或亚克力 | ¥30 |
| 按钮开关 | 轻触开关 x6 | ¥5 |
| BNC 接口 | 示波器标准接口 | ¥8 |
| 总计 | ¥188 |
如果你手头有现成的 STM32 开发板(比如正点原子、野火),成本还能再降。显示屏也可以用更便宜的 2.4 寸,看个人需求。
步骤 1:硬件设计
核心原理图
示波器的核心就三件事:信号采集、数据处理、波形显示。
信号输入 → 衰减/放大 → ADC 采集 → STM32 处理 → TFT 显示
↓ ↓
量程切换 DMA 传输
输入衰减电路
STM32F4 的 ADC 输入范围是 0-3.3V,但我们要测的信号可能是 0-20V 甚至更高。所以需要衰减电路:
// 分压电阻计算:R1=30k, R2=10k → 衰减比 4:1
// 输入 0-20V → ADC 看到 0-5V (再加钳位保护到 3.3V)
void configure_attenuation() {
// 使用模拟开关切换不同衰减比
// 1X: 0-3.3V 量程
// 10X: 0-33V 量程
HAL_GPIO_WritePin(ATTEN_GPIO, ATTEN_PIN, GPIO_PIN_SET);
}
⚠️ 踩坑提醒: 一开始我没加钳位二极管,结果一次误测 12V 直接把 ADC 引脚烧了。后来加了两个 BAT54S 做钳位,安全多了。
ADC 采样配置
STM32F407 自带 3 个 12 位 ADC,最高采样率 2.4MSPS。我们用定时器触发 + DMA 传输,实现连续采样:
// main.c - ADC 配置
ADC_HandleTypeDef hadc1;
DMA_HandleTypeDef hdma_adc1;
void MX_ADC1_Init(void) {
ADC_ChannelConfTypeDef sConfig = {0};
hadc1.Instance = ADC1;
hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; // 42MHz
hadc1.Init.Resolution = ADC_RESOLUTION_12B;
hadc1.Init.ScanConvMode = DISABLE;
hadc1.Init.ContinuousConvMode = ENABLE; // 连续模式
hadc1.Init.DiscontinuousConvMode = DISABLE;
hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_NONE;
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
hadc1.Init.NbrOfConversion = 1;
hadc1.Init.DMAContinuousRequests = ENABLE; // DMA 连续请求
hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV;
if (HAL_ADC_Init(&hadc1) != HAL_OK) {
Error_Handler();
}
// 配置通道:PA1 (ADC1_IN1)
sConfig.Channel = ADC_CHANNEL_1;
sConfig.Rank = 1;
sConfig.SamplingTime = ADC_SAMPLETIME_3CYCLES; // 最快采样
if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) {
Error_Handler();
}
}
// DMA 配置:传输到缓冲区
#define ADC_BUFFER_SIZE 1024
uint32_t adc_buffer[ADC_BUFFER_SIZE];
void MX_DMA_Init(void) {
hdma_adc1.Instance = DMA2_Stream0;
hdma_adc1.Init.Channel = DMA_CHANNEL_0;
hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_adc1.Init.MemInc = DMA_MINC_ENABLE;
hdma_adc1.Init.Mode = DMA_CIRCULAR; // 循环模式
hdma_adc1.Init.Priority = DMA_PRIORITY_HIGH;
hdma_adc1.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
if (HAL_DMA_Init(&hdma_adc1) != HAL_OK) {
Error_Handler();
}
__HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1);
}
启动采样:
// 开始 ADC 采集(DMA 自动传输)
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE);
步骤 2:波形显示实现
TFT 屏幕我们用 SPI 接口驱动,刷新率能达到 30fps 以上。显示部分的核心是把 ADC 采样的数据点画到屏幕上:
// display.c - 波形绘制
#include "lcd.h"
#include "gui.h"
#define SCREEN_WIDTH 320
#define SCREEN_HEIGHT 480
#define WAVEFORM_COLOR 0x001F // 蓝色
#define GRID_COLOR 0x8430 // 灰色
void draw_grid(void) {
// 画网格线
GUI_SetColor(GRID_COLOR);
// 垂直线(时间刻度)
for (int i = 0; i < SCREEN_WIDTH; i += 40) {
GUI_DrawLine(i, 40, i, SCREEN_HEIGHT - 20);
}
// 水平线(电压刻度)
for (int i = 40; i < SCREEN_HEIGHT; i += 40) {
GUI_DrawLine(0, i, SCREEN_WIDTH, i);
}
// 画零电平基准线
GUI_SetColor(0xF800); // 红色
GUI_DrawLine(0, SCREEN_HEIGHT/2, SCREEN_WIDTH, SCREEN_HEIGHT/2);
}
void draw_waveform(uint32_t* data, uint16_t length) {
int16_t last_y = -1;
GUI_SetColor(WAVEFORM_COLOR);
for (int i = 0; i < length && i < SCREEN_WIDTH; i++) {
// ADC 值 (0-4095) → 屏幕 Y 坐标
uint16_t adc_value = data[i] & 0xFFF;
int16_t y = SCREEN_HEIGHT - 20 - (adc_value * (SCREEN_HEIGHT - 60)) / 4095;
// 限制 Y 坐标在显示区域内
if (y < 40) y = 40;
if (y > SCREEN_HEIGHT - 20) y = SCREEN_HEIGHT - 20;
// 画线段(抗锯齿可以后续优化)
if (last_y >= 0) {
GUI_DrawLine(i, last_y, i, y);
}
last_y = y;
}
}
void update_display(void) {
// 清屏
GUI_Clear(0x0000); // 黑色背景
// 画网格
draw_grid();
// 画波形
draw_waveform(adc_buffer, ADC_BUFFER_SIZE);
// 显示参数
GUI_ShowString(10, 10, "CH1: 5V/div", 12, 0xFFFF);
GUI_ShowString(10, 25, "Time: 1ms/div", 12, 0xFFFF);
// 刷新屏幕
LCD_Refresh();
}
步骤 3:触发功能实现
没有触发的示波器就像没有对焦的相机——根本看不清波形。我们实现一个简单的边沿触发:
// trigger.c - 触发控制
typedef enum {
TRIG_MODE_AUTO,
TRIG_MODE_NORMAL,
TRIG_MODE_SINGLE
} TrigMode_t;
typedef enum {
TRIG_EDGE_RISING,
TRIG_EDGE_FALLING
} TrigEdge_t;
typedef struct {
TrigMode_t mode;
TrigEdge_t edge;
uint16_t level; // 触发电平 (0-4095)
uint16_t timeout_ms;
} TriggerConfig_t;
TriggerConfig_t trig_config = {
.mode = TRIG_MODE_AUTO,
.edge = TRIG_EDGE_RISING,
.level = 2048, // 中间电平
.timeout_ms = 100
};
// 检测触发点
int find_trigger_point(uint32_t* buffer, uint16_t length) {
for (int i = 0; i < length - 1; i++) {
uint16_t curr = buffer[i] & 0xFFF;
uint16_t next = buffer[i + 1] & 0xFFF;
if (trig_config.edge == TRIG_EDGE_RISING) {
// 上升沿:从低于阈值到高于阈值
if (curr < trig_config.level && next >= trig_config.level) {
return i;
}
} else {
// 下降沿
if (curr >= trig_config.level && next < trig_config.level) {
return i;
}
}
}
return -1; // 未找到触发点
}
// 主循环中的触发处理
void oscilloscope_main_loop(void) {
static uint32_t last_trig_time = 0;
// 等待触发
int trig_pos = find_trigger_point(adc_buffer, ADC_BUFFER_SIZE);
if (trig_pos >= 0) {
// 找到触发点,更新显示
update_display();
last_trig_time = HAL_GetTick();
} else {
// 自动模式下,超时后强制刷新
if (trig_config.mode == TRIG_MODE_AUTO) {
if (HAL_GetTick() - last_trig_time > trig_config.timeout_ms) {
update_display();
last_trig_time = HAL_GetTick();
}
}
}
}
步骤 4:用户交互
用 6 个按钮实现基本控制:
// buttons.c - 按钮控制
// 按钮定义:UP, DOWN, LEFT, RIGHT, ENTER, BACK
void button_handler(uint8_t btn_id, ButtonEvent_t event) {
static uint8_t menu_selection = 0;
if (event == BUTTON_PRESS_SHORT) {
switch (btn_id) {
case BTN_UP:
// 增加垂直档位
adjust_vertical_scale(1);
break;
case BTN_DOWN:
// 减少垂直档位
adjust_vertical_scale(-1);
break;
case BTN_LEFT:
// 减小时基
adjust_timebase(-1);
break;
case BTN_RIGHT:
// 增大时基
adjust_timebase(1);
break;
case BTN_ENTER:
// 切换触发模式
toggle_trigger_mode();
break;
case BTN_BACK:
// 返回菜单
show_menu();
break;
}
}
}
常见问题排查
问题 1:波形显示不稳定,一直在抖动
– 原因: 触发设置不当或信号噪声太大
– 解决:
1. 检查触发电平是否在信号幅度范围内
2. 尝试切换触发边沿(上升/下降)
3. 在输入端加 100nF 电容滤波
4. 如果是低频信号,用自动触发模式
问题 2:高频信号显示失真
– 原因: 采样率不够或带宽限制
– 解决:
1. 降低时基(提高采样率)
2. 检查 ADC 采样时间配置(用 3 周期最快)
3. 确认信号频率不超过奈奎斯特频率(采样率/2)
4. STM32F4 的 ADC 最高 2.4MSPS,理论带宽 1.2MHz,实际建议测 500kHz 以内
问题 3:屏幕刷新太慢
– 原因: SPI 时钟太低或绘图算法效率低
– 解决:
1. 提高 SPI 时钟(最高 42MHz)
2. 使用 DMA 传输显示数据
3. 只刷新变化区域(局部刷新)
4. 降低波形点数(用抽取算法)
问题 4:测量值不准确
– 原因: ADC 参考电压漂移或分压电阻误差
– 解决:
1. 用精密万用表校准输入电压
2. 在软件中加入校准系数
3. 使用外部基准电压源(如 REF3033)
4. 选择 1% 精度电阻做分压网络
性能指标
做完后的实测数据:
| 参数 | 指标 |
|---|---|
| 带宽 | DC ~ 500kHz (-3dB) |
| 采样率 | 最高 2MSPS |
| 垂直分辨率 | 12 位 (4096 级) |
| 垂直档位 | 1V/div ~ 10V/div |
| 时基范围 | 10μs/div ~ 1s/div |
| 输入阻抗 | 1MΩ (并联 20pF) |
| 最大输入 | ±35V (DC+AC) |
| 屏幕刷新 | 30fps |
| 电池续航 | 约 4 小时 |
这个性能对付数字电路调试、传感器信号分析、电源纹波测量都够用了。当然,跟几千块的台式示波器比还是有差距,但考虑到 1/10 不到的价格,性价比还是很香的。
扩展建议
如果你想继续升级这个项目:
1. 增加第二通道 – 用 STM32 的第二个 ADC,实现双踪显示
2. 添加 FFT 功能 – 用 DSP 库做频谱分析
3. 支持 U 盘存储 – 把波形数据存成 CSV 格式
4. 蓝牙/WiFi 连接 – 手机 APP 远程查看波形
5. 自动测量 – 频率、周期、峰峰值、占空比等参数
6. 李萨如图形 – X-Y 模式观察相位关系
代码我已经放到 GitHub 上了,欢迎 star 和 PR:
– 项目地址:https://github.com/makeronsite/stm32-oscilloscope
– 原理图/PCB:KiCad 工程文件
– 固件:STM32CubeIDE 工程
总结
这台便携式示波器从设计到调试,我大概花了两个周末。最大的收获不是省了多少钱,而是真正理解了示波器的工作原理。以前用台式示波器时,那些触发、采样、带宽的概念都很抽象,现在自己实现一遍,瞬间就通了。
对于经常在外面跑现场的工程师,或者预算有限的学生党,这个项目非常值得尝试。就算不买配件,光看代码学习 ADC、DMA、定时器这些外设的使用,也是很好的实践。
最后提醒一句:测量高压信号时一定要注意安全!这台示波器的输入是浮地的,不能直接测市电。如果需要测 220V,请配合隔离变压器或高压差分探头使用。
希望这篇博客文章对您有所帮助!
相关资源:
– STM32F407 数据手册
– 项目 GitHub 仓库
– ADC 应用笔记 AN2834