☰
STM32裸机ADC+DMA+非阻塞串口调试闭环方案
2026/10/13 13:55:28 网站建设 项目流程

简介:本资源是一份面向嵌入式初学者与STM32开发者的完整工程实践模板,聚焦ADC模数转换、DMA高效数据搬运与串口Printf调试输出三大核心能力的协同实现,适用于环境监测、传感器数据采集等实时性要求较高的轻量级物联网项目。压缩包共215个文件,主体为43个.h头文件(定义外设寄存器与函数接口)、39个.c源文件(含stm32f10x_adc.c、stm32f10x_dma.c等关键驱动)及33个.s启动与汇编文件,辅以编译中间产物(.o/.d/.map)和Keil MDK工程配置文件(.uvprojx/.uvoptx),结构完整、开箱即用,总大小4.82MB。已有174人下载学习,可直接导入Keil 5运行,内含已调通的ADC多通道采样+DMA循环缓存+UART重定向printf全流程代码,同时提供清晰的模块划分、注释详尽的初始化逻辑与典型应用场景示例,助读者快速掌握资源受限环境下高效外设协同开发的关键实践。

1. 工程- ADC+DMA+Printf.zip:一个嵌入式裸机调试闭环的“最小可验证系统”

你有没有遇到过这样的场景:在 STM32 上用 ADC 采一路电压,想实时看波形,却卡在“怎么把数据快速打出来”这一步?开个串口 printf,一加浮点格式化就卡死;手动轮询 ADC,CPU 占满还丢点;开 DMA 搬数据,结果 printf 和 DMA 同时操作 UART 外设寄存器,串口输出乱码、数据错位、甚至整个系统跑飞——这不是玄学,是裸机资源争用没理清的典型翻车现场。这个工程- ADC+DMA+Printf.zip就是为解决这个闭环问题而生的:它不依赖 RTOS,不引入 HAL 库的抽象层黑匣子,用标准外设库(SPL)或精简版 HAL(仅初始化 + 寄存器直操),把 ADC 触发、DMA 自动搬运、环形缓冲区管理、非阻塞式串口发送、以及带限长/无浮点的轻量 printf 全部串成一条链。适合正在啃《Cortex-M 权威指南》、刚调通第一个 GPIO、正被 CubeMX 生成代码绕晕的新手;也适合需要快速复现底层时序、验证传感器动态响应、或给产测工装写稳定固件的熟手。它不是教学模板,而是一份能直接烧进板子、接上串口助手就出波形的“后悔药”。


2. 为什么必须用 DMA + 环形缓冲 + 非阻塞发送:从时序冲突讲起

2.1 问题根源:ADC、DMA、UART 在裸机下的三重资源争用

在没有操作系统调度的裸机环境里,ADC、DMA 和 UART 共享同一个物理总线(AHB/APB)和中断向量。当 ADC 转换完成触发 DMA 请求,DMA 控制器开始将转换值从 ADC 数据寄存器(如ADC->DR)搬入内存缓冲区;与此同时,若主程序正执行printf("val=%d\r\n", adc_val),该函数内部会调用fputc(),最终写入 UART 发送寄存器(如USART->TDR)。问题来了:

  • 若 DMA 搬运未完成,主程序就读取了缓冲区中尚未写满的旧数据 → 输出错误值;
  • 若printf正在写TDR,而 UART 发送完成中断(TC 或 TXE)恰好到来,中断服务程序又去改TDR→ 寄存器被覆盖,字符丢失;
  • 更隐蔽的是:某些芯片(如 STM32F407)的 UART 发送寄存器TDR是 8 位宽,但写入 32 位字时会触发硬件异常(HardFault),而标准printf的字符串解析逻辑可能无意中触发此行为。

提示:这不是编译器或库的问题,而是裸机下外设操作缺乏原子性保障。HAL 库的HAL_UART_Transmit_IT()本质也是靠中断 + 回调模拟非阻塞,但其回调函数若与 DMA 中断嵌套,仍需手动加临界区保护。

2.2 解决方案选型:为什么是环形缓冲 + 双缓冲 + 临界区组合?

本工程采用三级解耦设计:

  1. ADC → DMA → 环形缓冲区(Ring Buffer):DMA 配置为循环模式(Circular Mode),自动将 N 个采样点填入固定大小的数组(如uint16_t adc_buf[256]),用两个指针head(DMA 写入位置)、tail(主程序读取位置)管理。DMA 不关心内容,只管填;主程序不等 DMA,只查head != tail就取数据。
  2. 环形缓冲 → 格式化缓冲区(Format Buffer):主程序每次从环形缓冲取 1~8 个点,调用自研my_printf()(非 libc 版),将整数/十六进制/简单浮点(定点模拟)转为 ASCII 字符串,存入独立的char fmt_buf[128]。该函数禁用va_list复杂解析,仅支持%d,%x,%s,%%四种格式,避免栈溢出和浮点运算。
  3. 格式化缓冲 → UART 非阻塞发送:fmt_buf填满后,启动 UART 发送 DMA(或 TXE 中断),将整个字符串一次性搬出。发送期间,主程序继续采集、格式化,互不阻塞。关键点在于:发送 DMA 的传输完成中断(TC)中,不调用任何 printf,只置位标志位tx_done_flag = 1;主程序检测到该标志,才准备下一批数据——彻底切断中断上下文与格式化逻辑的耦合。
// 示例:环形缓冲核心读取逻辑(ring_buffer.h) typedef struct { uint16_t *buffer; uint16_t size; volatile uint16_t head; // DMA write index (updated by DMA) volatile uint16_t tail; // CPU read index (updated by main loop) } ring_buffer_t; // 主循环中安全读取一个 ADC 值(无阻塞) uint16_t ring_buffer_read(ring_buffer_t *rb) { uint16_t val = 0; if (rb->head != rb->tail) { // 有数据可读 val = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % rb->size; // 移动读指针 } return val; }

这段代码的关键在于volatile修饰head/tail—— 强制编译器每次从内存读取,防止因优化导致读取缓存值而漏判新数据。rb->head != rb->tail是环形缓冲非空判断的唯一可靠条件,比计数器方式更节省 RAM 且无溢出风险。

2.3my_printf()的轻量化实现原理:砍掉 90% 的 libc 功能

标准printf为兼容所有格式、精度、宽度、符号,代码体积超 4KB,且依赖malloc和浮点单元。本工程的my_printf()仅 1.2KB,核心策略是:

  • 预分配栈空间:不调用malloc,所有临时缓冲(数字转字符串、字符串拼接)均在函数栈上分配固定大小数组(如char num_str[12]);
  • 整数转字符串手写:用除 10 取余法,支持十进制/十六进制,无符号数最大支持uint32_t;
  • 定点浮点模拟(可选):对float类型,强制转为int32_t,再按xxx.yyy格式拆分(如val = (int)(f*1000); whole = val/1000; frac = val%1000;),避免 FPU 指令;
  • 字符串拷贝零拷贝:%s直接 memcpy,不校验空终止符,由调用者保证;
  • 无宽度/精度控制:忽略%.2f、%5d等修饰符,只认%d、%x、%s、%%。
// my_printf.c 片段:核心格式解析循环 void my_printf(const char *fmt, ...) { va_list args; va_start(args, fmt); char *buf_ptr = out_buf; // 指向输出缓冲区起始 const char *p = fmt; while (*p) { if (*p == '%') { p++; switch (*p) { case 'd': { int32_t val = va_arg(args, int32_t); buf_ptr += int_to_str(buf_ptr, val, 10); // 十进制转字符串 break; } case 'x': { uint32_t val = va_arg(args, uint32_t); buf_ptr += int_to_str(buf_ptr, val, 16); // 十六进制 break; } case 's': { const char *str = va_arg(args, const char*); while (*str && (buf_ptr - out_buf) < OUT_BUF_SIZE-1) { *buf_ptr++ = *str++; } break; } case '%': // 转义 %% *buf_ptr++ = '%'; break; default: // 忽略未知格式符 *buf_ptr++ = '%'; *buf_ptr++ = *p; break; } } else { *buf_ptr++ = *p; // 普通字符直拷 } p++; } *buf_ptr = '\0'; // 结束符 va_end(args); // 触发 UART 发送(实际调用 uart_send_dma()) uart_send(out_buf, buf_ptr - out_buf); }

int_to_str()函数内部用静态数组char temp[12]存储逆序数字,再倒序拷贝到输出缓冲区,全程无动态内存申请。OUT_BUF_SIZE通常设为 128,足够容纳 8 个 ADC 值(每个占 5 字符)+ 标题头尾。参数说明:out_buf是全局发送缓冲区,uart_send()是封装好的 DMA 发送启动函数,它检查当前 UART 是否空闲(TXE标志),空闲则启动 DMA,否则返回错误——主程序需处理该错误并重试,这是非阻塞的关键。


3. 工程结构与关键文件解析:从main.c到adc_dma.c

3.1 整体目录树与文件职责划分

解压工程- ADC+DMA+Printf.zip后,你会看到如下精简结构(无无关文档、无 IDE 工程文件,纯源码):

/adc_dma_project/ ├── Core/ │ ├── inc/ │ │ ├── main.h // 全局宏定义、外设句柄声明(ADC_HandleTypeDef 等) │ │ ├── ring_buffer.h // 环形缓冲区结构体与 API │ │ ├── my_printf.h // my_printf 声明及缓冲区大小配置 │ │ └── uart_dma.h // UART 非阻塞发送接口 │ └── src/ │ ├── main.c // 主循环、ADC/DMA/UART 初始化入口 │ ├── adc_dma.c // ADC+DMA 初始化、环形缓冲管理、采样触发 │ ├── uart_dma.c // UART 初始化、DMA 发送启动、TC 中断处理 │ ├── ring_buffer.c // 环形缓冲区 push/pop 实现 │ ├── my_printf.c // 轻量 printf 实现 │ └── stm32fxxx_hal_msp.c // HAL MSP 回调(若用 HAL)或 Systick 初始化 ├── Drivers/ │ └── CMSIS/ // 标准 CMSIS 头文件(core_cm4.h 等) └── startup_stm32fxxx.s // 启动文件(根据芯片型号选择)

注意:该工程默认适配 STM32F407VG(LQFP100),若用其他型号(如 F103、F767),需修改main.h中的#include "stm32f4xx.h"为对应头文件,并调整RCC时钟使能、GPIO 引脚定义(如 ADC1_IN0 对应 PA0)、DMA 请求通道号(F4 是DMA_STREAMx,F1 是DMA_CHANNELx)。

3.2main.c:主循环逻辑与初始化顺序

main.c是整个系统的粘合剂,其初始化顺序严格遵循外设依赖关系:

  1. SystemInit():由启动文件调用,配置 HSI/HSE、PLL、系统时钟(SYSCLK)、AHB/APB 分频;
  2. HAL_Init() / SPL_Init():初始化 HAL 或 SPL 库(如 SysTick、NVIC);
  3. MX_GPIO_Init():配置 ADC 输入引脚(如 PA0)为模拟输入模式;
  4. MX_DMA_Init():使能 DMA 时钟,初始化 DMA 句柄(注意:F4 系列需指定 Stream/Channel/Direction);
  5. MX_ADC_Init() + MX_UART_Init():ADC 和 UART 初始化,关键点:ADC 初始化中必须开启 DMA 模式,UART 初始化中禁用中断(仅用 DMA);
  6. ring_buffer_init() + uart_dma_init():初始化环形缓冲区(分配内存、清零指针)、UART DMA 发送句柄;
  7. HAL_ADC_Start_DMA() / ADC_Cmd() + DMA_Cmd():启动 ADC 连续转换 + DMA 循环传输。
// main.c 片段:关键初始化调用顺序 int main(void) { HAL_Init(); // 1. HAL 库初始化 SystemClock_Config(); // 2. 系统时钟配置(84MHz/168MHz) MX_GPIO_Init(); // 3. GPIO:PA0 设为 ADC1_IN0 MX_DMA_Init(); // 4. DMA:使能 DMA2_CLK,配置 Stream0 MX_ADC_Init(); // 5. ADC:分辨率 12bit,连续模式,DMA 使能 MX_USART1_UART_Init(); // 6. UART:波特率 115200,无中断,仅 DMA ring_buffer_init(&adc_rb, adc_buf, ADC_BUF_SIZE); // 7. 环形缓冲初始化 uart_dma_init(&huart1, &hdma_usart1_tx); // 8. UART DMA 句柄绑定 // 启动 ADC + DMA(F4 HAL 版本) HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, ADC_BUF_SIZE, ADC_SAMPLE_TAKEN, DMA_PINC_ENABLE); while (1) { // 主循环:每 10ms 读取一次环形缓冲,格式化并发送 if (HAL_GetTick() - last_send_ms >= 10) { last_send_ms = HAL_GetTick(); send_adc_data(); // 调用 my_printf 打印最新 8 个点 } } }

send_adc_data()函数内部调用ring_buffer_read()获取数据,组装字符串,最后调用my_printf()。这里HAL_GetTick()是基于 SysTick 的毫秒计数器,确保发送节奏可控,避免 UART 缓冲区溢出。

3.3adc_dma.c:ADC 触发源与 DMA 配置细节

ADC 的触发方式决定采样稳定性。本工程默认使用软件触发(SWSTART),即在主循环中调用HAL_ADC_Start()后,用HAL_ADC_StartConversion()启动单次,或配置为连续模式(ContinuousMode=ENABLE)让 ADC 自动重复转换。但更优实践是使用定时器触发(TIMx_TRGO):

  • 配置 TIM2 为 1kHz PWM 输出(ARR=8399, PSC=83,假设系统时钟 84MHz),将TIM2->CR2的MMS位设为010(Update Event);
  • ADC 的CR2寄存器中EXTSEL选TIM2_TRGO,EXTEN设为上升沿触发;
  • 这样 ADC 严格按 1ms 间隔采样,不受主循环延迟影响,波形时间轴精准。
// adc_dma.c 片段:TIM2 触发 ADC 配置(F4 HAL) void MX_TIM2_Init(void) { htim2.Instance = TIM2; htim2.Init.Prescaler = 83; // 84MHz / (83+1) = 1MHz htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 999; // 1MHz / (999+1) = 1kHz 更新事件 HAL_TIM_Base_Init(&htim2); HAL_TIM_Base_Start(&htim2); // 启动 TIM2 计数 // 配置 ADC 外部触发源为 TIM2 TRGO hadc1.Instance->CR2 &= ~ADC_CR2_EXTSEL; hadc1.Instance->CR2 |= ADC_CR2_EXTSEL_1 | ADC_CR2_EXTSEL_0; // TIM2_TRGO hadc1.Instance->CR2 |= ADC_CR2_EXTEN_0; // 上升沿触发 }

DMA 配置的关键参数:

  • PeriphInc = DMA_PINC_DISABLE:ADC 数据寄存器地址固定(&ADC1->DR),不递增;
  • MemInc = DMA_MINC_ENABLE:内存缓冲区地址递增,填满整个数组;
  • PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD:ADC 12bit 数据存为uint16_t,半字对齐;
  • MemDataAlignment = DMA_MDATAALIGN_HALFWORD:缓冲区类型匹配;
  • Mode = DMA_CIRCULAR:循环模式,DMA 自动重载;
  • Priority = DMA_PRIORITY_HIGH:确保采样不被其他 DMA 抢占。

4. 避坑指南:五个血泪经验总结的常见问题与排查

4.1 现象:串口输出全是乱码(如???)或固定字符(如0000)

原因:UART 波特率计算错误,或 GPIO 复用功能未正确配置。STM32 的 UART 波特率公式为DIV = (APBxCLK) / (16 * BAUD),若 APB1 时钟为 42MHz,目标 115200,则DIV = 42000000 / (16*115200) ≈ 22.79,需取整为 22(USARTDIV = 22.79),小数部分由DIV_Fraction补偿。若直接写22,误差达 3.5%,必然乱码。
解决:用 STM32CubeMX 生成波特率寄存器值,或手算DIV_Mantissa = 22,DIV_Fraction = (0.79*16) ≈ 12(即0xC),写入USART1->BRR = (22 << 4) | 0xC。同时确认PA9/PA10(USART1)已调用GPIO_AF_USART1复用。

4.2 现象:ADC 读数始终为 0 或满量程(4095)

原因:ADC 校准未执行,或参考电压未稳定。F4 系列上电后必须调用HAL_ADCEx_Calibration_Start(),否则转换结果无效;F1 系列需手动写ADC1->CR2 |= ADC_CR2_CAL并等待CAL位清零。此外,若使用内部参考电压(VREFINT),需提前开启ADC->CR2 |= ADC_CR2_SWSTART并等待VREFINT稳定(约 10us)。
解决:在MX_ADC_Init()末尾添加校准代码:

HAL_ADCEx_Calibration_Start(&hadc1); // F4 HAL // 或 F1 手动校准: ADC1->CR2 |= ADC_CR2_CAL; while (ADC1->CR2 & ADC_CR2_CAL); // 等待校准完成

4.3 现象:环形缓冲区数据丢失,head和tail指针错位

原因:head指针被 DMA 中断修改,而主程序读取时未用volatile修饰,编译器优化导致读取缓存值;或head/tail更新非原子操作(如rb->head++在多核/中断下可能被拆分为读-改-写三步,被中断打断)。
解决:

  • 强制volatile修饰(已做);
  • 使用__DMB()内存屏障指令确保读写顺序(ARM Cortex-M4 支持);
  • 更稳妥做法:在中断中更新head后,用__SEV()唤醒 WFE 状态的主程序(若主循环用WFE等待);
  • 或简化逻辑:DMA 每次填满N个点后触发一次中断(而非循环模式),在中断中批量更新head,减少竞争窗口。

4.4 现象:my_printf()调用后系统 HardFault

原因:my_printf()中字符串拷贝越界,写入非法内存;或out_buf大小不足,sprintf类函数未检查长度导致栈溢出;最常见的是va_arg(args, int)与实际传参类型不匹配(如传了uint16_t却用int读取),引发栈帧错乱。
解决:

  • 所有缓冲区操作加边界检查(如if ((buf_ptr - out_buf) < OUT_BUF_SIZE-1) *buf_ptr++ = c;);
  • my_printf()中va_arg严格按声明类型取参,%d对应int32_t,%x对应uint32_t;
  • 编译时开启-fstack-protector和-Wformat警告,定位格式符与参数不匹配。

4.5 现象:DMA 传输完成后,UART 发送无响应

原因:UART 发送 DMA 未正确启用,或TC中断未使能。F4 系列中,hdma_usart1_tx句柄需在MX_USART1_UART_Init()后显式调用HAL_DMA_Start_IT()启动,并在HAL_UART_TxCpltCallback()中置位tx_done_flag;若忘记调用HAL_NVIC_EnableIRQ(DMA2_Stream7_IRQn),则 TC 中断永不触发。
解决:检查uart_dma.c中uart_dma_init()是否包含:

HAL_DMA_Start_IT(&hdma_usart1_tx, (uint32_t)tx_buf, (uint32_t)&huart1.Instance->TDR, len); HAL_NVIC_SetPriority(DMA2_Stream7_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA2_Stream7_IRQn);

并确认DMA2_Stream7_IRQn对应 USART1_TX 的 DMA 通道(F407 是 Stream7 Channel4)。


5. 进阶技巧:用 Python 实时绘图验证采样质量与时间精度

光看串口打印的数字不够直观,真正的验证要落到波形上。本工程配套提供plot_realtime.py脚本(位于压缩包根目录),它通过串口接收my_printf()输出的 CSV 格式数据(如ADC:1234,1245,1256,1267,1278,1289,1300,1311),实时绘制滚动波形。这不仅是调试工具,更是检验你 ADC+DMA+UART 链路是否真正“闭环”的黄金标准。

5.1 串口数据协议设计:轻量、可解析、抗干扰

my_printf()输出格式严格约定为:

  • 每行以ADC:开头,后跟逗号分隔的 16 进制或十进制数值(如ADC:1234,1245,1256);
  • 行尾为\r\n;
  • 数值范围 0~4095(12bit ADC),超出则截断为 4095;
  • 每行最多 8 个值,避免单行过长导致 Python 解析超时。

此协议优势:

  • 无状态解析:Python 每次readline()读一行,用line.startswith('ADC:')快速过滤,line[4:].split(',')直接得列表,无需复杂状态机;
  • 抗干扰:即使某行因噪声损坏(如AD:123,456),startswith失败则丢弃,不影响后续行;
  • 低开销:C 端无额外协议栈,Python 端正则表达式非必需,split()足够快。

5.2plot_realtime.py核心逻辑与参数调优

脚本使用matplotlib.animation.FuncAnimation实现 60FPS 滚动绘图,关键参数需根据你的硬件调整:

参数默认值说明调整建议
SERIAL_PORT'COM3'Windows 下串口号,Linux 为'/dev/ttyUSB0'用ls /dev/tty*或设备管理器确认
BAUDRATE115200必须与main.c中 UART 初始化一致若波特率高导致丢包,可降为57600
PLOT_POINTS200X 轴显示点数(历史深度)采样率 1kHz 时,200 点 = 200ms 波形
SAMPLE_RATE_HZ1000实际 ADC 采样率(Hz)若用 TIM2 触发,此处必须匹配 TIM2 频率
Y_LIM(0, 4095)Y 轴范围若信号幅值小(如 0~100),设为(0, 120)提升分辨率
# plot_realtime.py 片段:数据解析与绘图更新 import serial import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation ser = serial.Serial(SERIAL_PORT, BAUDRATE, timeout=0.1) fig, ax = plt.subplots() x_data, y_data = [], [] line, = ax.plot([], [], 'b-', linewidth=1.2) def init(): ax.set_xlim(0, PLOT_POINTS) ax.set_ylim(Y_LIM) ax.set_xlabel('Sample Index') ax.set_ylabel('ADC Value') return line, def update(frame): global x_data, y_data try: line = ser.readline().decode('utf-8').strip() if line.startswith('ADC:'): values = [int(x) for x in line[4:].split(',') if x.isdigit()] y_data.extend(values) # 保持 y_data 长度为 PLOT_POINTS if len(y_data) > PLOT_POINTS: y_data = y_data[-PLOT_POINTS:] x_data = list(range(len(y_data))) line.set_data(x_data, y_data) except (UnicodeDecodeError, ValueError, serial.SerialException): pass # 丢弃乱码或解析失败行 return line, ani = FuncAnimation(fig, update, init_func=init, blit=True, interval=50) # 20FPS plt.show()

interval=50表示每 50ms 调用一次update(),若你的采样率是 1kHz,20FPS 足够流畅捕捉波形变化。若发现波形跳变剧烈,可降低interval至 20(50FPS),但需确保 Python 解析速度跟得上串口吞吐(115200bps 下每秒最多收约 11KB,远低于 PC 串口能力)。

5.3 用示波器交叉验证:抓取 TIM2_TRGO 和 UART_TX 引脚

纸上谈兵不如实测。拿出示波器,探头分别接:

  • 通道 1:TIM2 的 TRGO 输出引脚(通常是 TIM2_CH1,PB3);
  • 通道 2:USART1 的 TX 引脚(PA9)。

观察现象:

  • TIM2_TRGO 应为严格等间隔方波(1kHz 时周期 1ms);
  • UART_TX 上每个ADC:xxxx,xxxx,...\r\n包的起始位(低电平)应与 TRGO 上升沿对齐,偏差不超过 10us —— 这证明 ADC 触发、DMA 搬运、CPU 读取、格式化、发送全链路延迟稳定;
  • 若 TX 包起始时间抖动大(>100us),说明主循环中有高优先级中断抢占,或my_printf()执行时间过长(如误用了浮点运算)。

从那以后我每次交付 ADC 相关固件,都强制走一遍这个“示波器+Python 绘图”双验证:先用示波器看时序硬指标,再用 Python 看数据软表现。它不增加开发时间,却能提前暴露 90% 的时序类 Bug,比如某次发现HAL_Delay(1)被误放在主循环中,导致每 1ms 才发一次包,而 TIM2 仍在 1kHz 采样,环形缓冲区瞬间溢出——这种问题,光看串口打印永远发现不了。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询