STM32F103 ADC采集链路:DMA触发、LCD显示与串口通信避坑

简介:面向嵌入式初学者与STM32开发者的ADC采集、液晶显示与串口上传例程包,以STM32F103ZET6为核心,解决模拟电压采集后在LCD实时显示并通过UART向外发送的典型应用问题。工程围绕12位ADC多通道采样、LCD驱动与GPIO控制、UART全双工通信及串口协议参数配置展开,并保留标准外设库、启动文件、系统延时与USMART调试组件等模块,便于理解从采样、显示到通信的完整链路。压缩包共88个文件,以38个.h头文件和37个.c源文件为主体,辅以少量txt说明、hex固件及Keil工程配置,整体约241KB,目录划分清晰。已有12086人学习下载,适合需要到手可用例程、快速搭建实验环境或对照排查ADC与串口问题的读者参考。

1. 一块屏、一根串口线:这套 ADC 采集演示到底解决了什么问题

手里有块最小系统板,插上串口线,液晶屏上跳出U=3.294V,同时电脑的串口助手每 100ms 收到一行$V,3.294,4092*1A。画面本身没什么技术含量,但它同时跑通了 ADC 采集、液晶显示、串口通信三条最容易各自翻车的链路,而这三条恰好是绝大多数嵌入式项目在第一个月就会撞上的部分。

这套代码适合两类人:刚学完 GPIO 点灯、想找个能把外设串起来的练手项目的初学者;以及手上有个传感器要读数、要本地看数、还要把数据丢给上位机的工程师。它不追求实时示波器那种性能,但把「模拟量进来、数字量出去、本地看得见、远端收得到」这条最基础的通路做完整了,后面加通道、换传感器、改协议都是在这条链路上贴模块。我见过太多项目卡在第一步——ADC 读数在飘、串口发出来是乱码、屏幕刷得一闪一闪,其实都不是玄学,是配置顺序和缓冲区设计的问题。

2. 硬件与工程骨架:引脚分配、时钟树和目录分层

动手前先把资源分配钉死,比写代码更重要。引脚一旦定错,后面调 ADC 时钟、调 SPI 速率全是在错误前提下做无用功。这块板子的外设需求其实很少:一个模拟输入、一组 SPI 屏、一路串口,再加一个心跳灯。

2.1 引脚与外设资源清单

下面是这套工程实际使用的引脚分配,可以直接照抄,也可以按自己的板子改。模拟输入选了 PA0,因为它属于 ADC1 的通道 0,走线最短,旁边没有高频信号经过。

引脚外设功能方向说明
PA0ADC1_IN0模拟输入被测电压,外部串 1kΩ、对地挂 100nF
PA5SPI1_SCK复用推挽LCD 时钟
PA6SPI1_MISO浮空输入本工程只写不读,可悬空
PA7SPI1_MOSI复用推挽LCD 数据
PA2 / PA3 / PA4GPIO 输出推挽LCD 的 RST / DC / CS 控制脚
PA9 / PA10USART1_TX/RX复用推挽 / 浮空115200-8-N-1
PC13GPIO 输出推挽板载 LED,1Hz 翻转,用来判断主循环还活着

有一点容易被忽略:PA0 配成模拟输入之后,GPIO 的上下拉必须彻底关掉。F1 系列在输入模式下 CRL 的 CNF 位写成00就是模拟模式,此时施密特触发器断开,引脚上的电压原样送进采样保持电容。如果误配成浮空输入,读数会莫名其妙地偏,而且偏多少取决于你手离板子有多近。

2.2 目录结构:bsp 与 app 怎么切

我一般把这个体量的工程分成两层:bsp只负责寄存器配置和硬件时序,app只负责业务逻辑,两边靠一组干净的接口通信。这样以后换屏、换串口引脚,只改 bsp,app 一行不动。

adc_lcd_uart/ ├── cmsis/ # 芯片头文件与启动文件 ├── bsp/ │ ├── bsp_adc.c/h # ADC + DMA 配置,对外只暴露原始值 │ ├── bsp_tim.c/h # TIM3,提供采样节拍 │ ├── bsp_lcd.c/h # SPI 屏驱动,字符/字符串/局部填充 │ └── bsp_uart.c/h # 串口初始化 + 环形缓冲发送 ├── app/ │ ├── app_sample.c/h # 滤波、电压换算 │ └── app_display.c/h # 刷屏节流、帧格式化 └── main.c # 只做初始化和主循环调度

这个分层的关键在于接口设计,而不是目录好看。bsp_adc对外只给两个东西:一个uint16_t bsp_adc_get_raw(uint8_t ch),一个float bsp_adc_get_voltage(uint8_t ch)。上层永远拿不到寄存器地址,也永远不需要知道 DMA 缓冲区有多长。等哪天你从 ADC1 换到 ADC2,或者改成外部 ADS1115 那种 I2C 方案,改的只是一个 c 文件。

2.3 时钟树决定精度:ADCCLK 与采样时间怎么算

F103 的 ADC 时钟来自 PCLK2 的分频,最大不能超过 14MHz。HSE 8MHz 经 PLL 九倍频得到 72MHz 主频之后,PCLK2 就是 72MHz,所以 ADC 预分频只能选 6,得到 12MHz。这个数字必须心里有数,因为它直接决定单次转换耗时。

转换时间公式是「采样时间 + 12.5 个周期」。如果采样时间设成 239.5 周期,一次转换就是(239.5 + 12.5) / 12MHz ≈ 21µs,理论上单通道能做到 47ksps 左右,但工程里没必要跑满,常见做法是 1kHz 到 10kHz 之间取值,把多余的算力留给显示和通信。

采样时间的选择跟信号源内阻强相关。信号源内阻越大,采样保持电容充到稳定值需要的时间越长,采样时间就必须拉长。我的血泪经验是:先用 239.5 周期把读数跑稳,确认链路没问题,再往下调到 55.5 或 28.5 周期看读数是否还稳定;如果一调小就开始飘,说明前级阻抗太高,应该在硬件上加电压跟随器,而不是在软件里硬扛。

3. ADC 采集链路拆解:从定时器触发到 DMA 搬运的完整通路

采集这条链路上有三件事要同时成立:ADC 得被正确启动、转换结果得有人及时搬走、搬走的数据得足够干净。很多人卡在第一步——代码写了但读数不变,或者读数一直在 4095 顶死,本质上都是启动顺序和校准没做。

3.1 ADC1 初始化:寄存器逐位讲

下面这段直接操作寄存器,不依赖任何库。用哪套 IDE 都无所谓,寄存器名字来自 CMSIS 头文件,编译出来行为完全一致。

#include "stm32f10x.h" #define ADC_BUF_LEN 8u /* DMA 缓冲区长度,双半区各 4 个样本 */ #define ADC_CH0 0u static volatile uint16_t g_adc_buf[ADC_BUF_LEN]; void bsp_adc_dma_init(void) { /* 1. 开时钟:GPIOA、ADC1 在 APB2,DMA1 在 AHB */ RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_ADC1EN; RCC->AHBENR |= RCC_AHBENR_DMA1EN; /* 2. PA0 配成模拟输入:CRL 低 4 位清零即可,CNF=00 MODE=00 */ GPIOA->CRL &= ~(0xFu << 0); /* 3. ADCCLK = 72MHz / 6 = 12MHz,绝对不能超过 14MHz */ RCC->CFGR &= ~RCC_CFGR_ADCPRE; RCC->CFGR |= RCC_CFGR_ADCPRE_DIV6; /* 4. DMA1 通道 1 是 ADC1 的固定映射通道,换不了 */ DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR; DMA1_Channel1->CMAR = (uint32_t)g_adc_buf; DMA1_Channel1->CNDTR = ADC_BUF_LEN; DMA1_Channel1->CCR = DMA_CCR_MSIZE_0 /* 内存宽度 16 位 */ | DMA_CCR_PSIZE_0 /* 外设宽度 16 位 */ | DMA_CCR_MINC /* 内存地址自增 */ | DMA_CCR_CIRC /* 循环模式,永不停止 */ | DMA_CCR_HTIE /* 半满中断 */ | DMA_CCR_TCIE; /* 全满中断 */ /* 5. ADC1 规则组:单通道,通道 0,采样 239.5 周期 */ ADC1->CR1 = 0; /* 单通道不需要扫描模式 */ ADC1->SQR1 = 0; /* L=0000,规则组 1 个转换 */ ADC1->SQR3 = ADC_CH0; /* 第 1 个转换对应通道 0 */ ADC1->SMPR2 |= (7u << 0); /* SMP0=111,239.5 周期 */ /* 6. 使能 DMA 请求 + 连续转换,先不置 ADON */ ADC1->CR2 = ADC_CR2_DMA | ADC_CR2_CONT; /* 7. 上电 -> 复位校准 -> 启动校准,顺序不能乱 */ ADC1->CR2 |= ADC_CR2_ADON; for (volatile uint32_t i = 0; i < 7200; i++) { } /* 约 100us,等 tSTAB */ ADC1->CR2 |= ADC_CR2_RSTCAL; while (ADC1->CR2 & ADC_CR2_RSTCAL) { } ADC1->CR2 |= ADC_CR2_CAL; while (ADC1->CR2 & ADC_CR2_CAL) { } /* 8. 第二次置 ADON 才真正开始转换,这是 F1 特有的坑 */ ADC1->CR2 |= ADC_CR2_ADON; DMA1_Channel1->CCR |= DMA_CCR_EN; }

这段代码里有三个参数值得单独说。ADC_CR2_CONT打开连续转换,ADC 一次转换完立刻开始下一次,配合 DMA 循环模式就是一条永不枯竭的数据流。SMPR2里的7对应 239.5 周期,是 F1 能给的最长采样窗口,先用最保险的值把链路跑通,再谈优化。RSTCAL和CAL这套校准流程不能省,出厂校准值存在 ADC 内部,不执行校准直接用,零点误差可能有十几个 LSB,换算成电压就是几十毫伏。

最后那个「第二次置 ADON」是很多人第一次写 ADC 时翻车的地方。F1 参考手册写得很清楚:ADON 从 0 变 1 只是给 ADC 上电,要真正启动转换,必须再写一次 1。如果只写一次,表现就是读数寄存器一直是 0,你会怀疑人生半小时。

3.2 DMA 循环搬运:为什么不用中断逐次读

用转换完成中断逐次读 DR 也能跑,但代价是每 21µs 进一次中断。1kHz 采样时无所谓,一旦采样率上到 20kHz,中断上下文切换的开销就开始吃掉主循环的时间,显示刷新会明显卡顿。DMA 的思路是把「搬运」这件事交给硬件,CPU 只在数据攒够一批之后被叫醒一次。

循环模式下的缓冲区是环形的,DMA 指针走到末尾自动回到开头,中间会在半满和全满两个位置各触发一次中断,这就是所谓的双缓冲。这样 CPU 处理前半区的时候,DMA 正在写后半区,两边互不干扰。

static volatile uint16_t g_sample_buf[4]; /* 供上层读取的稳定副本 */ static volatile uint8_t g_sample_ready = 0; void DMA1_Channel1_IRQHandler(void) { /* 半满:前 4 个样本已经稳定,可以安全拷走 */ if (DMA1->ISR & DMA_ISR_HTIF1) { DMA1->IFCR = DMA_IFCR_CHTIF1; for (uint8_t i = 0; i < 4u; i++) { g_sample_buf[i] = g_adc_buf[i]; } g_sample_ready = 1; } /* 全满:后 4 个样本就绪 */ if (DMA1->ISR & DMA_ISR_TCIF1) { DMA1->IFCR = DMA_IFCR_CTCIF1; for (uint8_t i = 0; i < 4u; i++) { g_sample_buf[i] = g_adc_buf[4u + i]; } g_sample_ready = 1; } }

IFCR那两行清除标志位必须写,而且要用「写 1 清零」的方式,用&=取反去清是无效的,这个细节在手册的 DMA 章节里是单独一段。拷贝用for循环而不是memcpy,是因为缓冲区只有 4 个元素,函数调用开销反而更大。

提示:如果你只想跑单通道,其实最简单的做法是把ADC_BUF_LEN设成 1,DMA 每次覆盖同一个位置,主循环直接读g_adc_buf[0]就行,连中断都不用开。双缓冲是为多通道和高速采样准备的,别为了「看起来专业」把简单问题复杂化。

3.3 TIM3 触发与采样率计算

连续转换模式下采样率是 ADC 自己说了算,你想要固定 1kHz 就不行了。工业场景里更常见的是定时器触发:TIM3 每次更新事件产生一个 TRGO 脉冲,ADC 收到这个脉冲才启动一次转换,采样间隔由定时器精确控制,不受代码执行时间影响。

改法很小:把ADC_CR2_CONT去掉,换成外部触发。F1 的 EXTSEL 编码里100对应 TIM3_TRGO,所以直接左移 17 位填进去。

void bsp_tim3_trigger_init(uint32_t sample_hz) { /* TIM3 挂在 APB1,APB1 分频系数为 2 时定时器时钟翻倍到 72MHz */ RCC->APB1ENR |= RCC_APB1ENR_TIM3EN; /* 计数频率 = 72MHz / (PSC+1),这里固定到 1MHz */ TIM3->PSC = 72u - 1u; /* 更新频率 = 1MHz / (ARR+1) = sample_hz */ TIM3->ARR = (1000000u / sample_hz) - 1u; /* 更新事件产生 TRGO,这是 ADC 的触发源 */ TIM3->CR2 = TIM_CR2_MMS_1; /* MMS=010,更新事件作 TRGO */ TIM3->CR1 |= TIM_CR1_CEN; /* ADC 侧:去掉 CONT,改成 TIM3_TRGO 触发 */ ADC1->CR2 &= ~ADC_CR2_CONT; ADC1->CR2 &= ~(7u << 17); ADC1->CR2 |= (4u << 17); /* EXTSEL=100 -> TIM3_TRGO */ ADC1->CR2 |= ADC_CR2_EXTTRIG; /* 允许外部触发 */ ADC1->CR2 |= ADC_CR2_SWSTART; /* 先软启动一次,把序列推起来 */ }

传入sample_hz为 1000 时,ARR算出 999,实际采样率误差在晶振精度范围内,可以忽略。这里有个隐藏约束:ARR 必须大于零,所以sample_hz不能超过 1MHz,实际工程里也很少超过 50kHz,因为单次转换本身就要 21µs。

要留意的还有 TIM3 的时钟来源。APB1 预分频系数只要不是 1,定时器时钟就会自动乘以 2,所以 PCLK1 是 36MHz 而 TIM3CLK 是 72MHz。如果你的工程把 APB1 分频改成了 1,PSC就得跟着改,否则采样率会差一倍。

3.4 数字滤波:滑动平均配中值

硬件上加了 RC 之后,读数一般还剩 ±3~5 个 LSB 的抖动,这个量级用软件滤一下就够了。我的习惯是先中值再平均:中值去掉偶发的尖峰(比如继电器动作时的干扰),平均压掉白噪声。

#define FILTER_WIN 8u static uint16_t s_win[FILTER_WIN]; static uint8_t s_idx = 0; static uint32_t s_sum = 0; static uint16_t median_of_three(uint16_t a, uint16_t b, uint16_t c) { if (a > b) { uint16_t t = a; a = b; b = t; } if (b > c) { b = c; } return (a > b) ? a : b; } uint16_t app_filter_push(uint16_t raw) { /* 滑动窗口:进来一个,挤出去一个,和值增量更新,不用每次重算 */ s_sum -= s_win[s_idx]; s_win[s_idx] = raw; s_sum += raw; s_idx = (s_idx + 1u) % FILTER_WIN; /* 取最近三个做中值,抑制单点尖峰 */ uint8_t i1 = (s_idx + FILTER_WIN - 1u) % FILTER_WIN; uint8_t i2 = (s_idx + FILTER_WIN - 2u) % FILTER_WIN; uint8_t i3 = (s_idx + FILTER_WIN - 3u) % FILTER_WIN; return median_of_three(s_win[i1], s_win[i2], s_win[i3]); } float app_get_voltage(void) { /* 用窗口和值算平均,比中值更平滑,两者结合效果最好 */ uint32_t avg = s_sum / FILTER_WIN; return (float)avg * 3.3f / 4096.0f; }

窗口长度 8 是个折中:太小滤波效果不明显,太大响应变慢,测阶跃信号时会有明显拖尾。如果被测信号变化很慢(比如电池电压监测),窗口可以放大到 32;如果要看快速变化,宁可砍到 4 并依赖硬件 RC。

换算公式里的4096而不是4095,是因为 12 位 ADC 的量化台阶是Vref/4096,用 4095 会在满量程附近产生固定偏差。这个差别只有 0.8mV,但做校准对比的时候会被抓出来。

4. 两条输出通道:LCD 局部刷新与串口环形缓冲

采集到干净的数据只是半成品,还得让人看得见、让机器收得到。这两条输出通道的实现思路完全不同:屏幕刷新怕慢,串口发送怕堵,各自有各自的坑。

4.1 LCD 显示:只刷数字不刷屏

屏幕闪烁的根源几乎都是整屏清屏或者大面积重绘。一帧 320×240 的 16 位色全屏刷新是 150KB 数据,SPI 跑 18MHz 也要 60ms 以上,主循环里每 100ms 来一次,肉眼就能看见明显的刷屏感。

正确做法是只重绘变化的那几个字符区域,而且字符串长度必须定宽,否则上次的「9」会留下半截。

/* 定宽格式化:整数部分 1 位、小数 3 位、对齐用空格补齐 */ void app_display_voltage(float v) { char line[16]; int mv = (int)(v * 1000.0f + 0.5f); /* 转成毫伏整数,避免 %f */ int ip = mv / 1000; int fp = mv % 1000; /* 固定 12 字符宽度,覆盖上一次内容,多出的部分用空格吃掉 */ snprintf(line, sizeof(line), "U=%d.%03dV ", ip, fp); /* 只在固定坐标写这一行,不清屏、不刷背景 */ lcd_draw_string(8, 40, line, COLOR_WHITE, COLOR_BLACK); }

lcd_draw_string内部按字符取模,每个字符 8×16 像素,写之前先用背景色填满这个字符的矩形,再画字模点阵。这样即使新字符比旧字符窄,也不会残留。

用整数毫伏而不是%f,是个很实际的考虑:某些精简 C 库默认不带浮点格式化,%f会输出0.000或者直接乱码,而且在 Cortex-M3 上软浮点格式化一次要几百微秒,1kHz 刷新时开销很可观。全部转成整数运算,既稳又快。

提示:屏幕刷新频率不需要跟采样率一致。采样 1kHz、屏幕 10Hz 更新完全够人看。在app_display里加一个计数器,每 100 次采样刷一次屏,能省下大量 SPI 时间。

4.2 串口发送:环形缓冲加 printf 重定向

串口最忌讳的是「发一帧阻塞等一帧」。115200 波特率下发 20 个字节大约 1.7ms,如果主循环里直接轮询发送,这 1.7ms 什么都干不了,采样节拍就被拖歪了。

环形缓冲的思路是:业务层只管往缓冲里塞,中断服务程序负责一个字节一个字节往外吐。塞满了就丢新数据或者覆盖最旧的,具体策略看业务需求。

#define TX_BUF_SIZE 256u static uint8_t s_tx[TX_BUF_SIZE]; static volatile uint16_t s_head = 0; /* 写指针,只在主循环改 */ static volatile uint16_t s_tail = 0; /* 读指针,只在中断里改 */ void bsp_uart_send(const uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { uint16_t next = (s_head + 1u) % TX_BUF_SIZE; if (next == s_tail) { return; /* 缓冲满,整帧丢弃,避免半帧污染 */ } s_tx[s_head] = data[i]; s_head = next; } USART1->CR1 |= USART_CR1_TXEIE; /* 打开发送空中断,把中断踢起来 */ } void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_TXE) { if (s_head != s_tail) { USART1->DR = s_tx[s_tail]; s_tail = (s_tail + 1u) % TX_BUF_SIZE; } else { USART1->CR1 &= ~USART_CR1_TXEIE; /* 没数据了,关中断 */ } } }

TXEIE的开关逻辑是这段代码的核心。只有缓冲里有数据才开中断,发完立刻关掉,否则 TXE 标志会一直置位,中断反复进出,CPU 就被打死了。这个「用完就关」的模式在 SPI、I2C 的中断发送里都通用。

要让printf走这条通道,在 GCC 工具链下重定向_write即可:

#include <sys/stat.h> int _write(int fd, char *ptr, int len) { (void)fd; bsp_uart_send((uint8_t *)ptr, (uint16_t)len); return len; }

注意这里只是把数据塞进环形缓冲,函数立刻返回,实际发送在中断里完成。有人在_write里加while(!(USART1->SR & USART_SR_TC))等发送完成,那就又变回阻塞了,等于白做环形缓冲。

4.3 数据帧格式:让上位机好解析

帧格式这件事,前期随便定,后期改起来要了命。我的做法是一开始就加上帧头、分隔符和校验,成本几乎为零。

void app_send_frame(float v, uint16_t raw) { char frame[32]; int mv = (int)(v * 1000.0f + 0.5f); /* 冒号前是载荷,用来算校验 */ int n = snprintf(frame, sizeof(frame), "$V,%d.%03d,%u", mv / 1000, mv % 1000, raw); uint8_t sum = 0; for (int i = 0; i < n; i++) { sum ^= (uint8_t)frame[i]; /* 逐字节异或,简单够用 */ } n += snprintf(frame + n, sizeof(frame) - n, "*%02X\r\n", sum); bsp_uart_send((uint8_t *)frame, (uint16_t)n); }

输出长这样:$V,3.294,4092*1A。$是帧头,,分隔字段,*后面两位十六进制是异或校验,\r\n结尾。上位机按行读、按分隔符切、校验不过就丢弃,逻辑十行就能写完。

改用这套格式之后,串口助手上一眼就能看出哪一帧被干扰了。之前用纯数字格式的时候,丢一个字节整个解析就错位,排查起来毫无头绪。

5. 避坑与排查:读数跳变、串口乱码、屏幕花屏的现场记录

前面几章的代码照抄能跑,但真正上板子的时候,问题往往出在没人写进文档的地方。下面这五条都是我自己或者身边人踩过的,按「现象 → 原因 → 解决」记下来。

5.1 读数卡在 4095 或 0 不动

现象很吓人:屏幕上的电压值永远是 3.300V,或者永远是 0.000V,改输入电压毫无反应。

原因通常有三个。一是 PA0 没配成模拟输入,还保留着复位后的浮空输入状态,施密特触发器把信号整形了。二是ADON只置位了一次,ADC 停在「已上电但未启动」的状态,DR 寄存器保持上次的值。三是SQR3里的通道号写成了别的通道,采的是悬空的 PA1,读数自然乱飘或者顶死。

排查顺序建议从寄存器快照开始:在调试器里看ADC1->CR2的 bit0 是不是 1,看ADC1->SQR3低五位是不是 0,看GPIOA->CRL低四位是不是 0。三个都对还是不动,再量 PA0 上的实际电压。

5.2 数值持续抖动 ±0.2V

现象是读数在 3.10V 到 3.30V 之间来回跳,用万用表量输入点却是稳的。

这是典型的参考电压和地回路问题。VDDA 上只挂了一个 100nF 是不够的,模拟电源需要 10µF 钽电容加 100nF 陶瓷电容并联;VSSA 必须单点接到主地,不能就近随便找个数字地焊盘。另一个常见原因是采样时间太短,信号源内阻偏高时采样电容充不满,每次转换的初始电荷都不一样,读数就带随机性。

处理办法是先软件后硬件:把采样时间改到 239.5 周期看抖动是否收敛,能收敛说明是采样窗口问题;收敛不了就去查电源去耦和地平面。别急着上复杂的数字滤波,硬件没做好的话,滤出来的只是「稳定的错误值」。

5.3 串口助手里全是乱码

收到的是烫烫烫或者一堆不可打印字符,偶尔夹杂正确片段。

第一嫌疑是波特率对不上。如果主频用的是内部 HSI 而不是外部晶振,实际频率随温度和批次能偏 1% 以上,115200 下就会开始出错。F103 的 HSI 精度标称 ±1%,边界情况下串口就是不稳。换成 8MHz 外部晶振,PLL 倍频到 72MHz,问题基本消失。

第二嫌疑是浮点格式化被裁了。某些精简运行库默认不链接浮点格式化代码,printf("%f")输出的是空或者乱码,而整数部分正常,表现就是「一半对一半错」。改用整数毫伏打印立刻正常。

第三嫌疑是发送函数被中断打断。如果在printf过程中来了 ADC 中断,而中断里又调了printf,两边的缓冲区就会互相踩踏。所有串口输出统一走一个入口,不要在中断里打印。

5.4 屏幕刷新闪烁、数字有残影

现象是每次刷新时整行闪一下,或者 3.30V 变成 3.29V 之后,后面尾随一个淡淡的「0」。

整屏清屏是主要原因。lcd_fill(0, 0, 320, 240, BLACK)这种调用在 100ms 周期里跑一次,肉眼就能看见黑闪。改成只填字符矩形区域之后,闪烁立刻消失。

残影是因为字符串长度不一致。snprintf出来的U=3.29V比U=3.30V少一个字符,短的那个不会覆盖掉旧的长字符。解决办法就是 4.1 节里的定宽格式,用空格补齐到固定长度。

还有一种情况是 SPI 速率开得太高,杜邦线又长,屏幕上出现随机的彩色噪点。把 SPI 从 36MHz 降到 18MHz,或者把线缩短到 10cm 以内,问题就没了。

5.5 冷启动第一帧数据不可信

上电后第一次读到电压偏高或者偏低,跑几秒之后才稳定下来。

ADC 上电后需要 tSTAB 时间让内部参考稳定,典型值是 1µs,但实际板子上电时电源爬升慢,等 100µs 更保险。另一方面,校准必须在上电稳定后执行,顺序反了校准值就是错的。

还有一个容易忽略的点:DMA 缓冲区刚开的时候是全 0 或者随机值,DMA 还没填满一圈你就去读,读到的自然是脏数据。在初始化之后加个「丢弃前 N 次采样」的逻辑,或者等g_sample_ready标志置位之后再让主循环开始显示,问题就解决了。

6. 二次开发:把演示板变成自己的数据采集终端

这套代码离「能用的产品」还有距离,但改造路径很清晰,主要就三个方向。

扩到多通道。把ADC1->CR1的SCAN位置 1,SQR1的低四位填「通道数 - 1」,SQR3依次填通道号,比如测两路就写(1u << 0) | (4u << 5)表示通道 0 和通道 4。DMA 缓冲区长度改成「通道数 × 每通道样本数」,读的时候按buf[ch + i * CH_NUM]取。注意扫描模式下每个通道的采样时间可以独立配置,高阻抗的那一路单独拉到 239.5 周期,别搞一刀切。

补上两点校准。ADC 的零点误差和增益误差在批量产品上不能忽略。接一个可调电源,分别给它 0.5V 和 3.0V,记录原始码值,用下面这行算出系数存进 Flash:

/* 用两组已知点解出 y = k*x + b,之后每次换算都套这个式子 */ k = (v_high - v_low) / (float)(raw_high - raw_low); b = v_low - k * (float)raw_low; voltage = k * (float)raw + b;

验证方法。改完之后别只信屏幕,拿万用表同时量输入点和串口输出的数值,做一张误差表,记录 0.5V / 1.0V / 1.5V / 2.0V / 2.5V / 3.0V 六个点的偏差。偏差在 ±10mV 以内说明链路健康,超过 ±30mV 就要回头查参考电压和采样时间。这个方法我每次改完采集代码都会跑一遍,比盯着屏幕看数字靠谱得多。

最后提一个具体技巧:主循环里所有事情的节拍不要靠delay堆,而是用一个 1ms 的 SysTick 计数器做软定时。采样由定时器硬件触发、显示和串口发送按计数器分频,三条链路各自独立,谁也不阻塞谁。这个结构一旦立住,后面往里塞按键、SD 卡、无线模块都不用重构。从那以后我每次新起一个采集类项目,都是先把这条最小链路和这套节拍跑通,再动别的功能。希望帮到你。

本文还有配套的精品资源,点击获取