资讯详情

资讯详情

STM32 LCD实时波形绘制:ADC+DMA采样与屏幕刷新优化

简介面向嵌入式初学者及具备一定单片机基础的开发者这份STM32驱动3.5寸LCD实时绘制波形图的工程资料围绕数据采集与可视化场景串联GPIO时序控制、LCD初始化、RGB565像素格式、ADC采样处理、缓冲区更新与DMA刷新等知识点。压缩包共136个文件、约3.08MB以35个h头文件和33个c源文件为核心辅以Keil工程配置、编译中间文件、hex与map产物及少量脚本说明工程结构完整便于按模块查阅。已有4531人学习读者可据此对照波形缩放、逐点绘制和中断或轮询刷新机制理解实时显示中的时序与性能取舍也能把它作为不同分辨率屏幕适配、触控功能或简单图形界面扩展的参考底稿。1. 从一块小屏开始这个 STM32 波形项目到底解决什么问题调试电机电流环时串口波形工具最难受的不是精度而是延迟采样点先打包、再上传、再在电脑上画等看到毛刺转速已经变了。这个基于 STM32 的 LCD 实时绘制波形图项目把单片机 ADC 采样、定时器触发、DMA 搬运和 LCD 绘制图形连成一条本地链路采样值不离开板子就直接上屏。它适合做嵌入式硬件调试、传感器标定、教学实验的人手头有一块 SPI 或并口 TFT 屏、一个 STM32 最小系统就能把电流、电压、温度这类慢变量实时画出来。别把它当示波器替代品它的价值是低成本、低延迟、可改协议能把“看波形”这件事塞进单片机自己的循环里。2. 硬件与软件架构信号从引脚到像素的完整链路拿到源码先别急着编译。波形项目能不能跑七成取决于采样链路和屏幕总线是否匹配。先看硬件连接再看目录分层最后看定时器触发 ADC 加 DMA 的配置。这样后面改采样率、换屏幕都有落脚点不会在一堆初始化代码里来回翻。2.1 硬件连接与选型底线这个项目的最小硬件组成不复杂一块 STM32 主控、一块 TFT LCD、一个待测模拟信号源再加按键和串口。真正容易翻车的是电平和总线速度。ADC 引脚只能吃 0 到 3.3V 的电压测电流采样电阻、分压后的电池电压、传感器输出时前端要先做限幅和偏置。屏幕如果走 SPISCK、MOSI、CS、DC、RST、BL 六根线要接对DC 线尤其不能省它决定写的是命令还是数据。模块典型引脚说明模拟输入ADC_INx输入范围 0 到 3.3V超范围要分压或钳位LCD SPISCK / MOSI / CS / DC / RST / BL屏幕靠 DC 区分命令和数据BL 控制背光按键GPIO 输入建议外部上拉按下接地软件消抖串口TX / RX用于改量程、暂停、单次触发调试口SWDIO / SWCLK下载和在线调试选型上有个底线SPI 屏适合小尺寸、局部刷新并口屏适合全屏高刷。以 240×320 的 RGB565 屏为例一帧像素数据是 240×320×2 等于 153600 字节。SPI 时钟拉到 18MHz光传一帧就要六十多毫秒算下来十几帧每秒还是理想值。所以常见做法不是每帧全屏重画而是只刷新波形区域或者用行缓冲一截一截推。采样率也要和屏幕刷新率对齐定时器 10kHz 采样屏幕 30fps每帧最多显示三百多个点超出的点要么抽点要么做最大最小值包络不然毛刺会被抽没。提示先用万用表确认 ADC 引脚电压在 0 到 3.3V 之间再上电跑屏幕初始化。模拟前端烧一次后面所有波形都不可信。2.2 工程目录与模块分层源码包的目录一般不会太深但分层清不清楚直接决定二次开发成本。我习惯把它拆成 Core、Drivers、BSP、App 四层。Core 只放启动文件、时钟和中断入口Drivers 放 LCD 的读写时序和绘图原语BSP 把 ADC、定时器、按键、串口封装成BSP_xxx_Init()这类接口App 才做采样缓冲、触发判断、坐标映射和波形刷新。换屏幕只动 Drivers换传感器只动 BSP主循环几乎不用改。wave_lcd/ ├─ Core/ │ ├─ Inc/ // 主配置、中断声明 │ └─ Src/ // main.c、时钟、中断服务函数 ├─ Drivers/ │ └─ LCD/ // lcd_core.c、lcd_spi.c、font.c ├─ BSP/ │ ├─ bsp_adc.c // ADC DMA 采样 │ ├─ bsp_tim.c // 采样定时器 │ ├─ bsp_key.c // 按键扫描 │ └─ bsp_uart.c // 串口收发 ├─ App/ │ ├─ Waveform/ │ │ ├─ wave.c // 采样缓冲、状态机 │ │ ├─ wave_draw.c // 坐标映射、波形绘制 │ │ └─ wave_trigger.c // 边沿触发、单次捕获 │ └─ Protocol/ │ └─ cmd.c // 串口命令解析 └─ Docs/ // 接线说明、命令表分层之后接口要定死。比如波形模块对外只暴露Wave_Init()、Wave_PushSamples()、Wave_Process()、Wave_Draw()四个函数LCD 驱动只暴露LCD_Init()、LCD_DrawPixel()、LCD_FillRect()、LCD_BlitLine()。主循环不直接碰寄存器中断里也不画屏。很多单片机项目最后改不动就是因为main.c里既有 ADC 校准又有画点循环查一个问题要跳十几个文件。2.3 采样链路定时器触发 ADC 加 DMA实时波形最怕采样节拍不稳。用主循环里HAL_ADC_GetValue()轮询采样间隔会被画屏、按键、串口打断波形横向就会忽长忽短。合格的做法是定时器更新事件触发 ADCADC 每次转换完由 DMA 搬进缓冲区CPU 只处理半满和全满中断。这样采样节拍由硬件决定主循环慢一点也不会丢点。// 定时器产生固定频率的 TRGO作为 ADC 的外部触发源 void BSP_TIM3_Init(uint32_t sample_hz) { uint32_t tim_clk 72000000UL; // 常见主频按实际时钟树修改 uint32_t arr (tim_clk / sample_hz) - 1; TIM3-PSC 0; TIM3-ARR arr; TIM3-CR2 ~TIM_CR2_MMS; // 清触发输出选择 TIM3-CR2 | TIM_CR2_MMS_1; // 更新事件作为 TRGO TIM3-CR1 | TIM_CR1_CEN; } // ADC 配置为外部触发DMA 循环搬运 void BSP_ADC1_DMA_Init(uint16_t *buf, uint32_t len) { ADC1-CR1 | ADC_CR1_SCAN; // 单通道时也可保留 ADC1-CR2 | ADC_CR2_EXTSEL_2 | ADC_CR2_EXTSEL_1; // 选择 TIM3_TRGO ADC1-CR2 | ADC_CR2_EXTTRIG; // 允许外部触发 ADC1-CR2 ~ADC_CR2_CONT; // 单次转换由定时器重复触发 DMA1_Channel1-CPAR (uint32_t)ADC1-DR; DMA1_Channel1-CMAR (uint32_t)buf; DMA1_Channel1-CNDTR len; DMA1_Channel1-CCR | DMA_CCR_CIRC | DMA_CCR_MINC | DMA_CCR_HTIE | DMA_CCR_TCIE; DMA1_Channel1-CCR | DMA_CCR_EN; ADC1-CR2 | ADC_CR2_ADON; ADC1-CR2 | ADC_CR2_SWSTART; // 启动第一次之后靠 TRGO }上面两段代码的关键参数是sample_hz、arr和len。sample_hz是目标采样率arr由定时器时钟除以采样率再减一得到如果主频不是 72MHz这个值要跟着改。len是 DMA 缓冲区长度建议取 2 的幂比如 256 或 512半满中断处理前半段全满中断处理后半段主循环拿到的是一段连续样本。DMA 必须开循环模式否则采完一轮就停波形会卡在最后一屏。中断服务函数里只做一件事置位wave_half_ready或wave_full_ready不要在中断里做浮点运算和 LCD 写操作。采样率不是越高越好。ADC 本身有转换时间模拟前端也有建立时间定时器触发太快采样保持电容还没充到位读到的是上一个点的残影。常见做法是先用 1kHz 到 10kHz 跑通再根据信号带宽往上调。屏幕每帧能画的点数有限10kHz 采样配 30fps一帧三百多个点刚好够看趋势要看开关毛刺就把触发方式改成单次捕获把缓冲区拉长而不是硬堆刷新率。3. 核心绘制模块LCD 驱动、坐标映射与实时波形刷新采样链路通了接下来是像素。波形显示不好看通常不是算法多复杂而是绘图原语太慢、坐标映射用浮点、刷新策略全屏重画。这个项目的绘制模块可以拆成三块LCD 基础读写、采样值到 y 坐标的定点映射、行缓冲加脏矩形刷新。三块都做对SPI 屏也能跑出可用的实时感。3.1 LCD 初始化与绘图原语LCD 驱动第一件事是确认总线模式和屏幕 ID。SPI 屏常见模式 0 或模式 3初始化前要拉低复位、延时、再释放然后发一系列命令配置像素格式、扫描方向、窗口范围。绘图原语不要每个像素都切一次 DC 线批量写像素时把 DC 拉高连续发两个字节的高低位CS 保持低电平速度会差很多。#define LCD_W 240 #define LCD_H 320 static void lcd_write_cmd(uint8_t cmd) { LCD_DC_LOW(); LCD_CS_LOW(); spi_write_byte(cmd); LCD_CS_HIGH(); } static void lcd_write_data(const uint8_t *buf, uint32_t len) { LCD_DC_HIGH(); LCD_CS_LOW(); spi_write_buf(buf, len); // 底层可用 SPI 阻塞、DMA 或寄存器批量写 LCD_CS_HIGH(); } void LCD_DrawPixel(uint16_t x, uint16_t y, uint16_t color) { if (x LCD_W || y LCD_H) return; LCD_SetWindow(x, y, x, y); uint8_t c[2] { color 8, color 0xFF }; lcd_write_data(c, 2); } void LCD_FillRect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { if (x LCD_W || y LCD_H) return; if (x w LCD_W) w LCD_W - x; if (y h LCD_H) h LCD_H - y; LCD_SetWindow(x, y, x w - 1, y h - 1); uint8_t line[LCD_W * 2]; for (uint16_t i 0; i w; i) { line[i * 2] color 8; line[i * 2 1] color 0xFF; } LCD_DC_HIGH(); LCD_CS_LOW(); for (uint16_t row 0; row h; row) { spi_write_buf(line, w * 2); } LCD_CS_HIGH(); }LCD_DrawPixel适合画坐标轴和少量标记不适合逐点画整条波形。LCD_FillRect把一行颜色先拼好再按行批量推比循环调LCD_DrawPixel少很多窗口设置开销。参数color是 RGB565高字节在前还是低字节在前要看屏幕控制器接反了颜色会变成红蓝互换。LCD_SetWindow设置的是矩形窗口后续数据会按窗口自动换行画波形时只开波形区域的小窗口不要每次全屏开窗。3.2 采样值到屏幕坐标的映射ADC 原始值是 0 到 4095屏幕 y 坐标是 0 到 319中间要做线性映射。用浮点写起来快但在 M3 核上每帧几百个点都做浮点主循环会被拖慢。常见做法是定点整数运算先限幅再乘除并且给上下留边距不然波峰贴边看不清。typedef struct { int32_t min; int32_t max; uint16_t y_top; uint16_t y_bottom; } WaveScale; static uint16_t map_adc_to_y(uint16_t adc, const WaveScale *s) { int32_t v (int32_t)adc; int32_t span s-max - s-min; if (span 0) return s-y_bottom; // 防止除零 if (v s-min) v s-min; // 下限幅 if (v s-max) v s-max; // 上限幅 int32_t h s-y_bottom - s-y_top; return s-y_bottom - (uint16_t)(((v - s-min) * h) / span); }min和max是当前量程不一定等于 0 和 4095。测 1.65V 偏置的交流信号时可以把量程收窄到 1200 到 2900波形会占满屏幕。y_top和y_bottom是波形绘制区的上下边界通常留出顶部状态栏和底部坐标轴。映射结果反向用y_bottom - ...是因为屏幕 y 向下增大而电压越高波形越靠上。触发对齐也在这个环节做找到上升沿穿过阈值的样本下标把该点固定到 x 等于屏幕左侧偏移后面的点依次排过去波形就不会左右乱跑。3.3 双缓冲与脏矩形刷新全屏双缓冲在 STM32F103 这类 64KB RAM 的单片机上不现实240×320×2 一帧就 150KB。可行的做法是行缓冲加脏矩形只申请一行或几行像素的缓冲区每次刷新波形区的一行前后帧对比只重绘有变化的列。SPI 屏最怕“先清背景再画线”分两次推肉眼能看到闪烁把一行背景和波形点先拼进缓冲区再一次性推给屏幕撕裂会少很多。static uint16_t line_buf[LCD_W]; void Wave_DrawRow(const uint16_t *samples, uint16_t n, uint16_t y_base, uint16_t y_span) { for (uint16_t x 0; x LCD_W; x) { line_buf[x] COLOR_BG; // 先铺背景 } for (uint16_t i 0; i n; i) { uint16_t x (uint32_t)i * (LCD_W - 1) / (n - 1); uint16_t y y_base (samples[i] % y_span); // 示例映射实际用 map_adc_to_y if (y LCD_H) line_buf[x] COLOR_WAVE; } LCD_BlitLine(0, y_base, line_buf, LCD_W); // 一行拼好后整行推送 }Wave_DrawRow里的n是这一帧参与显示的点数y_base是波形区起始行line_buf长度等于屏宽。真正项目里不会只画一行而是把波形区按 8 行或 16 行分块每块拼好再推块与块之间用脏矩形判断是否需要刷新。判断方法很简单记录上一帧每列的最小 y 和最大 y新一帧只重绘这些列覆盖的行。SPI 时钟 18MHz 时一行 240 像素 480 字节推一行约 0.2ms推 320 行要 60 多毫秒只刷新波形区 200 行再跳过无变化列帧率能翻几倍。4. 主循环与交互按键、串口命令、暂停与量程切换波形能画出来只是第一步现场调试还要能停、能改量程、能单次抓取。这个项目的主循环采用前后台结构中断负责采样和置标志主循环负责处理样本、解析命令、刷新屏幕。按键和串口命令都走非阻塞轮询谁也不能在循环里死等。4.1 主循环状态机状态机只有三个状态RUN 连续刷新PAUSE 冻结当前波形SINGLE 等待一次触发后冻结。中断里收到半满或全满标志主循环把对应半区样本拷进显示缓冲区再做触发判断。绘制和采样解耦屏幕慢一点不会拖累 ADC。typedef enum { WAVE_RUN 0, WAVE_PAUSE, WAVE_SINGLE } WaveState; volatile uint8_t wave_half_ready; volatile uint8_t wave_full_ready; static WaveState state WAVE_RUN; void App_Loop(void) { if (wave_half_ready) { wave_half_ready 0; Wave_PushSamples(adc_buf, 0, ADC_BUF_LEN / 2); } if (wave_full_ready) { wave_full_ready 0; Wave_PushSamples(adc_buf, ADC_BUF_LEN / 2, ADC_BUF_LEN / 2); } Cmd_Poll(); // 串口命令非阻塞 Key_Scan(); // 按键扫描10ms 一次 if (state WAVE_RUN Wave_FrameDue()) { Wave_Draw(); } if (state WAVE_SINGLE Wave_Triggered()) { Wave_Draw(); state WAVE_PAUSE; } }Wave_PushSamples只做搬运和环形缓冲写入不做画点。Wave_FrameDue用HAL_GetTick()或定时器计数判断是否到了 33ms避免主循环跑多快就画多快。state切到 PAUSE 后采样可以继续也可以停 DMA看需求停 DMA 更省电但恢复时要重新对齐触发。按键扫描里做 10ms 消抖长按切自动量程短按暂停双击进单次。4.2 串口命令协议与参数表串口命令用简单的 ASCII 行协议一行一条空格分隔回车结束。命令表不用多够现场调就行。解析时先strtok取命令字再校验参数范围非法参数回ERR不要直接改寄存器。命令示例作用RATERATE 10000设置采样率内部换算定时器 ARRRANGERANGE 1200 2900设置显示量程TRIGTRIG RISE 2048上升沿触发阈值 2048RUNRUN连续刷新PAUSEPAUSE冻结波形SINGLESINGLE单次触发捕获DUMPDUMP把当前缓冲区按十六进制输出static void Cmd_Handle(char *line) { char *cmd strtok(line, ); if (cmd NULL) return; if (strcmp(cmd, RANGE) 0) { char *a strtok(NULL, ); char *b strtok(NULL, ); if (a b) { int32_t min atoi(a); int32_t max atoi(b); if (min max min 0 max 4095) { Wave_SetRange(min, max); uart_send(OK\r\n); } else { uart_send(ERR RANGE\r\n); } } } else if (strcmp(cmd, RATE) 0) { char *a strtok(NULL, ); if (a) { uint32_t hz atoi(a); if (hz 100 hz 50000) { BSP_TIM3_Init(hz); uart_send(OK\r\n); } } } else if (strcmp(cmd, PAUSE) 0) { Wave_SetState(WAVE_PAUSE); uart_send(OK\r\n); } }Cmd_Handle的关键是参数边界量程必须在 0 到 4095 之间采样率不能低于 100Hz也不能高到 ADC 转换不过来。改采样率时定时器 ARR 会变正在跑的 DMA 最好先停再改改完清一次缓冲区不然新旧样本混在一帧里波形会突然跳一下。串口接收用环形缓冲加空闲中断主循环里取行不要在中断里strtok。4.3 量程和触发方式自动量程每帧统计最小值和最大值加 10% 边距再慢速跟随。不要一帧一变否则屏幕上的波形会呼吸式缩放看着头晕。触发方式支持上升沿、下降沿和自由运行。上升沿触发适合看脉冲起点预触发缓冲留四分之一能看到触发前的基线。static int Wave_FindTrigger(const uint16_t *buf, uint16_t n, uint16_t th, int rising) { for (uint16_t i 1; i n; i) { if (rising buf[i - 1] th buf[i] th) return i; if (!rising buf[i - 1] th buf[i] th) return i; } return -1; }th是触发阈值rising为 1 找上升沿为 0 找下降沿。找到下标后显示缓冲从i - pretrigger开始取点负数就补基线。单次模式下一次触发后立刻切 PAUSE串口回TRIGGERED这样抓偶发毛刺不用盯着屏幕按暂停。5. 避坑与排查实时波形翻车的 5 个现场波形项目有几个坑很典型现象看着像玄学原因大多在时序、缓冲和电气连接上。下面五条是血泪经验里出现频率最高的按“现象、原因、解决”拆开方便对着板子查。5.1 波形满屏噪点像雪花现象屏幕上的波形不是一条线而是一片毛刺信号源本身很干净。原因通常有三类ADC 采样保持时间不够源阻抗太高采样电容没充到位模拟地和数字地混在一起屏幕 SPI 的开关噪声串进 ADC前端没有限幅信号超过 3.3V 被钳位二极管削顶。解决时先把信号源换成电池分压看噪点是否消失再缩短 ADC 采样时间或加电压跟随器屏幕排线远离模拟输入地线单点汇合。软件上可以加 3 点中值滤波但不要上重滑动平均否则毛刺也被抹平。5.2 波形撕裂、闪烁现象刷新时上半屏是新波形下半屏还是旧线或者整屏一闪一闪。原因多半是全屏清背景和画波形分两次推屏SPI 速度又慢人眼看到了中间态。解决是改行缓冲一行背景和波形点先拼进line_buf再一次性写窗口只刷新波形区状态栏和坐标轴不参与重绘刷新率降到 25 到 30fps给主循环留出处理命令的时间。如果屏幕支持开部分显示窗口别每帧全屏开窗。5.3 DMA 只采一轮就停现象上电后波形只更新一次之后adc_buf前半段有数后半段不变。原因常见是 DMA 配成了普通模式采完CNDTR归零就停或者半满中断里忘了清标志主循环一直卡在同一个半区还有HAL_ADC_Start_DMA的长度单位写错把字节数当成了样本数。解决是确认DMA_CCR_CIRC置位半满和全满回调只置标志主循环取完数据再清在线调试时看DMA1_Channel1-CNDTR是否在递减不递减就是没跑起来。5.4 屏幕白屏或花屏现象上电背光亮但全白或者偶尔花屏复位后又好。原因通常是复位时序不对RST 拉低时间太短DC 线在上电初始化阶段电平不对命令被当成数据SPI 模式选错模式 0 和模式 3 的采样沿不同初始化命令之间没加延时控制器还没准备好。解决是先读屏幕 ID读不到就查接线用逻辑分析仪看 CS、DC、SCK 三根线确认命令阶段 DC 为低初始化里该加 120ms 延时的地方别省背光串限流电阻别直接灌电流。5.5 帧率上不去按键也卡现象波形刷新只有几帧按键反应迟钝串口回命令也慢。原因多是在主循环里死等 SPI 发送完成每画一个点都切一次窗口坐标映射用了浮点串口printf阻塞ADC 中断里做了太多事。解决是 SPI 改 DMA 或批量写绘图用行缓冲映射改定点串口用环形缓冲加空闲中断中断里只置标志。帧率实在不够就把颜色深度降一档或者只画最近 5 秒的滚动窗口不画全历史。6. 二次开发把波形项目改成自己的数据采集器换传感器时先改BSP_ADC1_DMA_Init里的通道和采样时间再改前端分压。测 5V 信号电阻分压到 2.5V 以内测电流采样电阻加运放放大到 0 到 3.3V。采样率改BSP_TIM3_Init(hz)的入参同时把ADC_BUF_LEN调到能覆盖一帧显示点数。目标 20kHz 采样、30fps 显示一帧六百多点缓冲区至少 1024半满 512 点刚好一帧。目标采样率定时器 ARR72MHz每帧点数30fps建议缓冲1kHz71999332565kHz1439916651210kHz719933351220kHz35996661024加峰值检测很简单在Wave_PushSamples里维护一个peak_hold每个新样本和旧值比较大的留下。屏幕上用一条短横线标出峰值位置调电源纹波和电机电流尖峰时很好用。static uint16_t peak_hold(uint16_t old_peak, uint16_t new_sample) { return (new_sample old_peak) ? new_sample : old_peak; }验证顺序别反先接 1.65V 直流看线是否在屏幕中间再接 1Hz 方波看上升沿是否对齐触发点最后接真实信号改量程看是否削顶。每改一次采样率我都先把 DMA 停掉、清缓冲、重新初始化定时器再开 DMA不然新旧样本混在一起波形会突然抖一下。从那以后我每次动采样链路都强制走一遍“直流、方波、真实信号”三步省得在客户现场对着一屏雪花怀疑人生。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →