简介:本资源是一套基于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模式(典型配置:APB2@72MHz,SPI1@36MHz)
理论带宽 = 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_CLK@72MHz,16位数据总线)
理论带宽 = 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条指令(取址、读数据、发命令、发数据、等待BUSY)
- 240×320=76800像素 → 单帧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->DR:SPI数据寄存器地址,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 = MY=0 MX=1 MV=0 ML=0 RGB=1 MH=0 XX=0 XX=0 → 横屏、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_WIDTH=128:决定波形时间跨度。若ADC采样率=200Hz,则128点=0.64秒历史数据ring_head:环形缓冲写指针,(ring_head - WAVE_WIDTH) & 0xFF即最老有效数据位置x_pos++:X轴递增,实现从左到右的“扫描式”绘制,比逐点随机写入更易预测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_xy+e_x > 0 */ if (e2 <= dx) { err += dx; y0 += sy; } /* e_xy+e_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.0f:F103内置ADC最大值(12位)100.0f:Y轴显示高度,可根据需求改为200增强灵敏度- 边界检查
y_int < 120防止越界写显存,引发花屏
4. 定时与同步:TIM+ADC+DMA三级联动,掐准每一微秒
4.1 为什么不能用SysTick做波形刷新定时器?
SysTick是系统滴答定时器,优先级固定(最高),且无法与ADC硬件事件精确对齐。后果:
- ADC采样时刻与波形绘制时刻不同步 → 波形出现“呼吸式”抖动(周期≈100ms)
- 多任务环境下,SysTick中断可能被更高优先级任务抢占 → 刷新间隔忽长忽短
正确方案:用TIM定时器触发ADC,并用ADC转换完成事件触发DMA传输,形成硬件闭环:
// TIM2配置:200Hz更新频率(5ms周期) htim2.Instance = TIM2; htim2.Init.Prescaler = 72-1; // APB1=36MHz → 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。
参数说明:
Prescaler=72-1:将APB1时钟(36MHz)分频为500kHz计数器时钟Period=2500-1:500k/2500=200Hz,即每5ms采样一次ADC_SAMPLETIME_239CYCLES_5:保证12位精度所需最小采样时间(查F103手册Table 52)
4.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先发号施令(启动ADC),ADC执行完再通知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位色格式为RRRRRGGGGGGBBBBB(5-6-5),而代码中#define YELLOW 0xFFE0实际是RRRRRGGGGGGBBBBB格式,但部分驱动库误用0xFFFF(白色)作为背景色,导致颜色混合。
解决:严格按5-6-5格式定义颜色:
#define GREEN 0x07E0 // 00000 111111 00000 #define YELLOW 0xFFE0 // 11111 111111 00000 → R=31,G=63,B=0 #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) { // 仅重绘损坏区域(如Y=100~150行) LCD_FillRect(0, 100, 240, 50, BLACK); RedrawWaveRegion(100, 150); // 重绘该区域波形 expected_crc = calc_crc; // 更新期望值 } }逻辑说明:crc16_ccitt()用查表法会占256字节ROM,此处用位运算法仅占80字节,计算240×320像素(153600字节)耗时≈1.2ms(F103@72MHz),远小于全屏重绘的5.3ms。
参数说明:
0x1021:CRC16-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, ×tamp, 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", dtype=np.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。因为某次忘记清屏,上电瞬间闪出乱码,客户以为产品有严重缺陷——其实只是显存里残留了上次的垃圾数据。希望帮到你。
本文还有配套的精品资源,点击获取