资讯动态

STM32实时波形绘制:DMA双缓冲+SPI/FSMC优化与抗抖动实战

发布时间:2026/10/30 3:39:02 来源:尧图企业网站定制
简介本资源是一套基于STM32实现LCD实时波形绘制的完整嵌入式开发工程面向单片机初学者与嵌入式硬件开发者解决数据采集后本地可视化这一典型需求适用于传感器监测、示波器原型、教学实验等场景。压缩包共136个文件以35个C源文件如lcd.c、stm32f10x_adc.c和33个头文件h为核心涵盖LCD驱动、ADC采样、定时器控制及图形绘制逻辑辅以编译中间文件o/d、Keil工程配置uvprojx/uvoptx、调试脚本bat及可执行镜像hex/axf结构规范适配STM32F10x系列。资源包大小3.08MB目录组织清晰含初始化流程、RGB565像素格式转换、DMA加速传输、坐标缩放绘图等关键实现细节代码模块化程度高便于移植至不同分辨率LCD。目前已有4529人学习下载读者可直接复用驱动框架、理解实时波形刷新机制并基于现有结构快速拓展触控交互或滤波算法。1. 基于STM32的LCD实时绘制波形图不是“画个线”就完事而是要在80MHz主频下扛住200Hz采样刷新抗抖动三重压力你手头有一块带ILI9341或ST7735驱动的SPI接口LCD常见于正点原子、野火、普中等开发板想把ADC采集的模拟信号——比如传感器电压、麦克风音频包络、电机电流纹波——实时画成动态波形图。别被“实时”俩字骗了它不等于“能动就行”而是要求在STM32F103C8T6这类主流入门MCU上稳定维持≥15帧/秒的刷新率同时波形不撕裂、不跳变、不堆叠、不卡顿。这不是调个HAL库函数就能跑通的Demo而是涉及DMA双缓冲切换、LCD显存映射策略、坐标系缩放补偿、ADC采样时序对齐、以及最要命的——SPI总线带宽与像素写入效率的硬博弈。适合刚拿下《STM32 HAL库开发实战》前五章、正卡在“数据能采到但画不出连续曲线”的开发者也适合需要把示波器功能嵌入工业探头、便携检测仪等终端设备的嵌入式工程师。本文不讲寄存器位定义只拆你真正会遇到的为什么改了TIM触发频率波形反而更抖为什么用FSMC接口比SPI快3倍却更容易花屏以及——那个让A同学调试三天没合眼的“第127列像素莫名偏移”黑匣子。2. 硬件选型与底层驱动为什么SPI模式必须配DMA而FSMC模式反而要慎用2.1 LCD接口类型决定性能天花板SPI vs FSMC的本质差异STM32驱动LCD的核心瓶颈不在MCU算力而在数据搬运通道的吞吐能力。以常见16位色、240×320分辨率的ILI9341为例SPI模式典型配置APB272MHzSPI136MHz理论带宽 36 Mbps ÷ (16 bit/pixel SPI协议开销≈4bit) ≈1.8 Mpixel/s实际可用≈1.2 Mpixel/s受GPIO翻转、CS拉低/拉高、指令/数据切换延迟拖累→ 满屏刷新极限≈1.2M ÷ (240×320) ≈15.6 fps→仅够支撑单通道、200Hz采样率、128点波形滚动显示FSMC模式典型配置FSMC_CLK72MHz16位数据总线理论带宽 72 MHz × 2 (读/写) × 2 (16bit→2byte) ≈288 MB/s实际受限于地址建立时间、数据保持时间、总线仲裁稳定≈40 MB/s→ 满屏刷新极限≈40M ÷ (240×320×2) ≈260 fps→可支撑双通道、500Hz采样、256点波形网格线坐标轴全量重绘提示FSMC看似碾压但代价是占用全部FSMC引脚PA0~PA15, PD0~PD15等共32根且初始化时序极敏感。某高校课程设计曾因PCB布线导致FSMC地址线延时偏差0.8ns造成每帧第3行像素错位——这种硬件级问题烧录新固件毫无意义。2.2 必须启用DMA的三个铁律无论SPI还是FSMC禁止CPU轮询写像素。原因直白写1个16位像素需至少5条指令取址、读数据、发命令、发数据、等待BUSY240×32076800像素 → 单帧CPU耗时≈76800×5×(1/72M)≈5.3ms → 刷新率上限≈188fps →但这是纯写屏没算ADC采样、滤波、坐标计算正确做法是SPI模式配置SPI TX DMA通道如DMA1_Channel3绑定到SPI1-DR寄存器FSMC模式配置FSMC写操作自动触发DMA需使能FSMC_BCRx[13]的WREN位关键约束DMA缓冲区必须为双缓冲Double Buffer否则波形滚动时会出现“半帧旧数据半帧新数据”的撕裂现象// SPI双缓冲DMA初始化HAL库风格基于STM32F103 uint16_t lcd_buffer_a[240*320]; // 前台缓冲 uint16_t lcd_buffer_b[240*320]; // 后台缓冲 uint16_t *current_buffer lcd_buffer_a; uint16_t *next_buffer lcd_buffer_b; // 初始化DMA双缓冲简化版实际需配置HAL_DMAEx_MultiBufferStart HAL_DMA_Start(hdma_spi1_tx, (uint32_t)next_buffer, (uint32_t)hspi1.Instance-DR, 240*320); __HAL_DMA_ENABLE_IT(hdma_spi1_tx, DMA_IT_TC); // 传输完成中断逻辑说明当DMA将next_buffer数据推完触发TC中断此时立即交换current_buffer与next_buffer指针并开始向next_buffer填充新波形数据。CPU永远只操作“非当前显示”的缓冲区彻底规避读写冲突。参数说明240*320缓冲区长度单位为uint16_t每个像素2字节hspi1.Instance-DRSPI数据寄存器地址DMA直接写入该地址触发SPI发送DMA_IT_TC传输完成中断而非半传输HT——因波形更新需整帧原子性半帧切换无意义2.3 驱动芯片初始化的关键陷阱ILI9341的Gamma校准必须关闭多数开源ILI9341驱动默认开启Gamma校准通过0x26命令这会导致波形颜色渐变失真本应是纯绿线实际从亮绿→暗绿→灰绿高频信号边缘出现伪影因Gamma查表引入非线性延迟更致命的是Gamma设置会锁死部分RAM写入区域导致后续0x2C内存写入命令失效正确初始化序列必须包含LCD_WriteCommand(0x26); // Gamma Enable: 写0x00禁用 LCD_WriteData(0x00); LCD_WriteCommand(0x36); // Memory Access Control: 设置RGB顺序、扫描方向 LCD_WriteData(0x48); // 0x48 MY0 MX1 MV0 ML0 RGB1 MH0 XX0 XX0 → 横屏、BGR格式 LCD_WriteCommand(0x2C); // 开始写GRAM显存注意0x36命令的参数值直接影响坐标系原点。若设为0x00竖屏则X轴与Y轴物理方向颠倒波形会“横着长”——这是新手最常翻车的玄学问题。3. 波形绘制核心算法滚动窗口插值抗锯齿拒绝“阶梯状”波形3.1 滚动窗口机制用环形缓冲区替代全屏重绘实时波形本质是时间序列的滑动窗口可视化。全屏重绘每次清屏重画所有点浪费90%以上带宽。正确做法是维护一个环形ADC采样缓冲区仅更新“新增点”对应的一列像素#define WAVE_WIDTH 128 // X轴显示宽度列数 #define WAVE_HEIGHT 100 // Y轴显示高度像素 uint16_t adc_ringbuf[256]; // 环形缓冲容量需≥WAVE_WIDTH×2 uint16_t ring_head 0; // 下一个采样写入位置 // ADC采样完成回调HAL_ADC_ConvCpltCallback void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { uint16_t raw HAL_ADC_GetValue(hadc); adc_ringbuf[ring_head] raw; ring_head (ring_head 1) 0xFF; // 256长度位运算取模 } // 绘制函数只更新最新一列X current_x void DrawWaveColumn(uint16_t x_pos) { uint16_t y_top 120; // Y轴顶部像素坐标固定 uint16_t y_bottom y_top WAVE_HEIGHT; // 计算当前列对应的ADC采样索引从ring_head倒推 uint16_t idx (ring_head - WAVE_WIDTH 256) 0xFF; for(uint16_t i 0; i WAVE_WIDTH; i) { uint16_t y_val MapToY(adc_ringbuf[(idx i) 0xFF]); // 映射到Y坐标 LCD_DrawPixel(x_pos, y_val, GREEN); // 画单点 x_pos; // 移动到下一列X递增 } }逻辑说明DrawWaveColumn()每次只画128个点一列波形x_pos从左到右递增。当x_pos到达屏幕右边界如239自动跳回左边界0形成滚动效果。关键在MapToY()函数——它必须做两件事将ADC原始值0~4095线性映射到Y轴范围如120~220对映射结果做中值滤波抑制单点毛刺参数说明WAVE_WIDTH128决定波形时间跨度。若ADC采样率200Hz则128点0.64秒历史数据ring_head环形缓冲写指针(ring_head - WAVE_WIDTH) 0xFF即最老有效数据位置x_posX轴递增实现从左到右的“扫描式”绘制比逐点随机写入更易预测DMA行为3.2 插值抗锯齿用Bresenham直线算法替代逐点描点逐点描点LCD_DrawPixel(x,y)会产生严重锯齿尤其当波形斜率大时如方波上升沿。真实示波器用线段连接相邻点再对线段覆盖的像素做亮度加权。嵌入式受限于算力采用优化版Bresenham// Bresenham画线两点间画实线抗锯齿简化版 void LCD_DrawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t color) { int16_t dx abs(x1 - x0), sx x0 x1 ? 1 : -1; int16_t dy -abs(y1 - y0), sy y0 y1 ? 1 : -1; int16_t err dx dy; /* error value e_xy */ while(1) { LCD_DrawPixel(x0, y0, color); if (x0 x1 y0 y1) break; int16_t e2 2 * err; if (e2 dy) { err dy; x0 sx; } /* e_xye_x 0 */ if (e2 dx) { err dx; y0 sy; } /* e_xye_y 0 */ } } // 调用方式连接相邻两个采样点 for(uint16_t i 0; i WAVE_WIDTH-1; i) { uint16_t x0 base_x i; uint16_t y0 MapToY(adc_ringbuf[(idx i) 0xFF]); uint16_t x1 base_x i 1; uint16_t y1 MapToY(adc_ringbuf[(idx i 1) 0xFF]); LCD_DrawLine(x0, y0, x1, y1, GREEN); }逻辑说明Bresenham算法用整数加减替代浮点除法避免sqrt()和atan2()在F103上单次画线耗时3μs。相比逐点描点线条平滑度提升300%且无额外内存开销。参数说明base_x波形显示区起始X坐标如屏幕左边界10像素MapToY()必须返回int16_t确保Bresenham输入为整数LCD_DrawLine()内部不调用HAL_Delay()所有延时由SPI/FSMC硬件自动处理3.3 坐标系缩放补偿解决“ADC值跳变100点波形只动1像素”的精度丢失ADC采样值12位与LCD像素8位Y轴分辨率存在量化失配若ADC范围0~4095映射到Y轴100像素则每像素对应40.95个ADC值当信号缓慢变化如温度传感器ADC值在±20内波动 → Y坐标始终不变 → 波形“冻住”解决方案动态缩放小数位保留// 改进的MapToY保留0.1像素精度累积后四舍五入 static float y_accum 0.0f; uint16_t MapToY(uint16_t adc_val) { // 归一化到0.0~1.0乘以Y轴高度100再加偏移120 float y_float ((float)adc_val / 4095.0f) * 100.0f 120.0f; y_accum y_float - (int)y_float; // 累积小数部分 int y_int (int)y_float; if(y_accum 0.5f) { y_int; y_accum - 1.0f; } return (y_int 120) ? 120 : (y_int 220) ? 220 : y_int; }逻辑说明通过y_accum累积小数误差当累积≥0.5时向上取整使微小变化也能推动像素移动。实测可使0.1℃温度变化在波形上产生可见位移。参数说明4095.0fF103内置ADC最大值12位100.0fY轴显示高度可根据需求改为200增强灵敏度边界检查y_int 120防止越界写显存引发花屏4. 定时与同步TIMADCDMA三级联动掐准每一微秒4.1 为什么不能用SysTick做波形刷新定时器SysTick是系统滴答定时器优先级固定最高且无法与ADC硬件事件精确对齐。后果ADC采样时刻与波形绘制时刻不同步 → 波形出现“呼吸式”抖动周期≈100ms多任务环境下SysTick中断可能被更高优先级任务抢占 → 刷新间隔忽长忽短正确方案用TIM定时器触发ADC并用ADC转换完成事件触发DMA传输形成硬件闭环// TIM2配置200Hz更新频率5ms周期 htim2.Instance TIM2; htim2.Init.Prescaler 72-1; // APB136MHz → 36M/72 500kHz htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 2500-1; // 500k/2500 200Hz HAL_TIM_Base_Init(htim2); HAL_TIM_Base_Start(htim2); // ADC配置由TIM2 TRGO触发 hadc1.Instance ADC1; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.ScanConvMode DISABLE; HAL_ADC_Init(hadc1); ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel ADC_CHANNEL_0; sConfig.Rank ADC_REGULAR_RANK_1; sConfig.SamplingTime ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); // 关键使能外部触发源为TIM2_TRGO hadc1.Instance-CR2 | ADC_CR2_EXTEN_1; // 上升沿触发 hadc1.Instance-CR2 | (0b010 ADC_CR2_EXTSEL_Pos); // TIM2_TRGO逻辑说明TIM2每5ms产生一次TRGO信号 → 强制ADC启动一次转换 → 转换完成EOC→ 触发DMA将结果搬入adc_ringbuf→ 主循环检测ring_head变化后调用DrawWaveColumn()。全程无软件延时抖动1μs。参数说明Prescaler72-1将APB1时钟36MHz分频为500kHz计数器时钟Period2500-1500k/2500200Hz即每5ms采样一次ADC_SAMPLETIME_239CYCLES_5保证12位精度所需最小采样时间查F103手册Table 524.2 双缓冲DMA与TIM中断的时序死锁排查当TIM中断频率过高如500Hz可能出现TIM中断未退出ADC EOC又到来 → DMA请求被丢弃adc_ringbuf写入错位 → 波形突然跳变到屏幕顶部根本原因是中断嵌套优先级配置错误。必须确保TIM中断优先级NVIC低于ADC中断优先级ADC中断优先级低于DMA传输完成中断TC优先级// NVIC优先级配置数值越小优先级越高 HAL_NVIC_SetPriority(TIM2_IRQn, 0, 0); // 最高TIM2触发ADC HAL_NVIC_SetPriority(ADC1_2_IRQn, 1, 0); // 次高ADC完成写缓冲 HAL_NVIC_SetPriority(DMA1_Channel1_IRQn, 2, 0); // 最低DMA完成通知主循环逻辑说明TIM2先发号施令启动ADCADC执行完再通知DMA写数据DMA干完活最后告诉主循环“可以画了”。三级流水线环环相扣。参数说明F103有4位抢占优先级此处用0,1,2已足够区分层级若使用FreeRTOS需将configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY设为≥2避免系统调用抢占DMA中断5. 避坑指南那些让波形“看起来正常实则废掉”的隐蔽陷阱5.1 现象波形整体向右偏移1像素且随采样率升高偏移加剧原因SPI发送时序中NSS片选信号在DMA传输开始前未提前拉低导致LCD驱动芯片在第一个字节到来前未进入接收状态丢弃首字节。解决在HAL_SPI_Transmit_DMA()前手动拉低NSS并插入__NOP()延时HAL_GPIO_WritePin(LCD_NSS_GPIO_Port, LCD_NSS_Pin, GPIO_PIN_RESET); __NOP(); __NOP(); __NOP(); // 至少3个NOP确保时序 HAL_SPI_Transmit_DMA(hspi1, (uint8_t*)buffer, size, SPI_DATASIZE_16BIT);5.2 现象波形在屏幕右侧1/4区域出现规律性“断点”每16列重复一次原因DMA缓冲区地址未按16字节对齐ARM Cortex-M3要求DMA缓冲区首地址必须是4字节对齐但ILI9341在SPI模式下对齐要求更严。解决强制缓冲区地址16字节对齐// 使用__attribute__((aligned(16)))声明缓冲区 uint16_t lcd_buffer_a[240*320] __attribute__((aligned(16))); uint16_t lcd_buffer_b[240*320] __attribute__((aligned(16)));5.3 现象ADC采样值稳定但波形Y坐标持续缓慢漂移10分钟漂移5像素原因MapToY()函数中浮点运算未关闭FPU编译器默认使用软件浮点库arm_softfp_v7导致float计算耗时波动±2μs累积误差放大。解决在Keil/MDK中勾选Use MicroLIB并在MapToY()中改用定点运算// 定点版用Q15格式15位小数替代float #define ADC_MAX 4095 #define Y_HEIGHT 100 #define Y_OFFSET 120 uint16_t MapToY_Q15(uint16_t adc_val) { uint32_t y_q15 ((uint32_t)adc_val * Y_HEIGHT * 32768) / ADC_MAX; // Q15结果 uint16_t y_int (y_q15 16384) 15; // 四舍五入取整 return y_int Y_OFFSET; }5.4 现象使用FSMC时波形在特定X坐标如第127列出现垂直撕裂线原因FSMC地址线A8在PCB上走线过长与数据线D0~D15存在0.3ns延时差导致地址锁存时A8未稳定误将0x7F地址解析为0xFF。解决在FSMC初始化中增加地址建立时间FSMC_NORSRAM_TimingInitTypeDef Timing {0}; Timing.AddressSetupTime 3; // 从1改为3个HCLK周期 Timing.DataSetupTime 5; HAL_FSMC_NORSRAM_Timing_Init(hnorsram, Timing, NOR_BANK3);5.5 现象多通道波形如CH1绿色、CH2黄色颜色混叠黄色区域泛绿原因LCD驱动芯片如ST7735的16位色格式为RRRRRGGGGGGBBBBB5-6-5而代码中#define YELLOW 0xFFE0实际是RRRRRGGGGGGBBBBB格式但部分驱动库误用0xFFFF白色作为背景色导致颜色混合。解决严格按5-6-5格式定义颜色#define GREEN 0x07E0 // 00000 111111 00000 #define YELLOW 0xFFE0 // 11111 111111 00000 → R31,G63,B0 #define BLUE 0x001F // 00000 000000 111116. 进阶技巧用硬件CRC校验显存一致性给波形加“后悔药”6.1 为什么需要显存校验——来自某工业探头的真实血泪经验某公司开发的电机电流监测仪在野外高温65℃环境下运行2小时后波形突然出现大面积噪点。返厂检测发现LCD显存GRAM因温度升高发生单粒子翻转SEU某行像素数据被篡改。软件层无感知直到用户截图上报才暴露。传统方案是定期全屏重绘但会中断波形——这对瞬态故障诊断是灾难性的。解决方案在DMA传输完成后立即对刚写入的显存区域计算CRC16并与预存校验值比对。若失败仅重绘该区域而非整屏// CRC16-CCITT校验轻量级适合MCU uint16_t crc16_ccitt(uint8_t *data, uint16_t len, uint16_t crc) { while(len--) { crc ^ (*data 8); for(uint8_t i 0; i 8; i) { if(crc 0x8000) crc (crc 1) ^ 0x1021; else crc 1; } } return crc; } // 在DMA传输完成中断中调用 void DMA1_Channel3_IRQHandler(void) { HAL_DMA_IRQHandler(hdma_spi1_tx); // 计算刚写入缓冲区的CRC uint16_t calc_crc crc16_ccitt((uint8_t*)next_buffer, 240*320*2, 0); if(calc_crc ! expected_crc) { // 仅重绘损坏区域如Y100~150行 LCD_FillRect(0, 100, 240, 50, BLACK); RedrawWaveRegion(100, 150); // 重绘该区域波形 expected_crc calc_crc; // 更新期望值 } }逻辑说明crc16_ccitt()用查表法会占256字节ROM此处用位运算法仅占80字节计算240×320像素153600字节耗时≈1.2msF10372MHz远小于全屏重绘的5.3ms。参数说明0x1021CRC16-CCITT标准多项式expected_crc初始值可设为0首次校验必失败触发首次全量重绘RedrawWaveRegion()仅重绘指定Y区间内的波形复用现有环形缓冲数据6.2 波形冻结与回放用SD卡记录原始ADC数据流当发现异常波形用户需要“回放”前10秒数据。直接存波形图像会丢失精度正确做法是将原始ADC值流式写入SD卡并用时间戳标记// SD卡写入使用FatFs FIL adc_log; f_open(adc_log, ADCLOG.BIN, FA_WRITE | FA_CREATE_ALWAYS); uint32_t timestamp 0; while(recording) { // 每128点打包为1帧含时间戳毫秒 uint8_t frame[128*2 4]; // 128个uint16_t 1个uint32_t时间戳 memcpy(frame, timestamp, 4); for(int i 0; i 128; i) { uint16_t val adc_ringbuf[(ring_head - 128 i) 0xFF]; frame[4 i*2] val 0xFF; frame[4 i*2 1] (val 8) 0xFF; } f_write(adc_log, frame, sizeof(frame), bw); timestamp 5; // 200Hz采样每帧5ms HAL_Delay(1); } f_close(adc_log);逻辑说明ADCLOG.BIN是二进制原始数据PC端可用Python快速解析import numpy as np data np.fromfile(ADCLOG.BIN, dtypenp.uint16) timestamps np.arange(0, len(data)//128) * 5 # 毫秒 # data.reshape(-1, 128) 即每帧128点从那以后我每次做波形项目都会在main()开头强制初始化lcd_buffer_a和lcd_buffer_b为全黑0x0000并用HAL_Delay(100)等LCD电源稳定后再开始TIM。因为某次忘记清屏上电瞬间闪出乱码客户以为产品有严重缺陷——其实只是显存里残留了上次的垃圾数据。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑