简介:本资源是一份面向嵌入式初学者与STM32开发者的完整工程实践模板,聚焦ADC模数转换、DMA高效数据搬运与串口Printf调试输出三大核心能力的协同实现,适用于环境监测、传感器数据采集等实时性要求较高的轻量级物联网项目。压缩包共215个文件,以43个.h头文件(定义外设寄存器与接口)、39个.c源文件(含stm32f10x_adc.c等标准外设库及主逻辑)、33个.s启动汇编文件为主干,辅以.o目标文件、.d依赖文件及.uvprojx/.uvoptx等Keil MDK工程配置文件,结构完整、开箱即用,总大小4.82MB。已有176人学习下载,资源包含可直接编译运行的STM32F103C8最小系统工程,涵盖ADC多通道采样配置、DMA循环缓冲传输、重定向printf至UART的底层实现,以及配套的hex、map、lst等调试辅助文件,便于理解内存布局、定位中断异常与验证数据流完整性。
1. 这不是“ADC读个电压”那么简单:一个把裸机ADC、DMA搬运、串口Printf全链路打通的嵌入式硬核练手包
你有没有试过在STM32上用ADC采温感,结果串口打印出来的值忽高忽低、跳变几十LSB,查了半天发现是printf阻塞了DMA传输,而DMA又没配好双缓冲——最后发现不是代码逻辑错,是整个数据流节奏崩了。这个工程- ADC+DMA+Printf.zip,就是专治这种“看似简单、实则黑匣子”的嵌入式现场病的实战压缩包。它不讲原理图、不画时序波形,而是直接给你一套可编译、可烧录、可秒改、可验证的完整工程:从GPIO初始化、ADC单通道连续采样,到DMA内存循环搬运,再到通过重定向_write底层把printf无缝接进DMA管理的环形缓冲区,最终在串口助手上稳定输出带时间戳的浮点电压值(比如V: 3.287V @ 124ms)。适合刚学完HAL库但还没跑通过“多模块协同”的中级开发者,也适合需要快速复现ADC+DMA+串口日志场景的老手——它不教你“什么是DMA”,但它会告诉你“为什么DMA半传输中断里不能调printf”、“为什么__io_putchar必须是非阻塞的”、“怎么一眼看出DMA地址没对齐导致采样丢点”。这不是教学Demo,是能焊在你下一块PCB板子上的最小可行数据采集骨架。
2. 工程结构与技术栈:基于STM32F103C8T6的裸机级精简实现
这个工程没有用CubeMX生成一堆冗余文件,也没有引入RTOS或CMSIS-RTOS封装层,而是采用纯HAL库+少量寄存器操作补位的方式构建。核心目标很明确:让ADC采样、DMA搬运、串口输出三者形成闭环流水线,且每个环节的延迟和资源占用都可控、可测。整个工程目录结构极简,共5个关键文件夹:
/Inc ├── main.h // 全局宏定义、函数声明、关键参数(如ADC采样周期、DMA缓冲区大小) ├── stm32f1xx_hal_conf.h // HAL外设使能开关(只开RCC、GPIO、ADC、DMA、USART) /Drivers ├── STM32F1xx_HAL_Driver/ // 官方HAL库源码(已裁剪,仅保留ADC/DMA/USART相关.c/.h) /Src ├── main.c // 系统初始化、主循环(空循环,所有事由中断驱动) ├── adc_dma.c // ADC+DMA联合配置、启动、中断服务函数(含半传输/传输完成处理) ├── usart_printf.c // 串口重定向实现:_write() + 环形缓冲区 + DMA发送 ├── delay.c // 微秒级延时(SysTick实现,非HAL_Delay,避免阻塞) /Core ├── startup_stm32f103xb.s // 启动文件(标准MDK版本) ├── system_stm32f1xx.c // 系统时钟配置(72MHz HSE)提示:该工程默认使用外部8MHz晶振(HSE),若你的板子用的是内部RC(HSI),需修改
system_stm32f1xx.c中SystemClock_Config()函数内RCC_OscInitTypeDef结构体的OscillatorType和HSEState字段,并重新计算PLL倍频系数。
2.1 ADC配置:连续扫描模式+DMA请求触发
ADC模块采用单通道、连续转换模式(Continuous Conv. Mode),而非单次触发。这是保证数据流不间断的前提。关键配置如下(摘自adc_dma.c):
// ADC初始化结构体 ADC_ChannelConfTypeDef sConfig = {0}; hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = DISABLE; // 单通道,不扫描 hadc1.Init.ContinuousConvMode = ENABLE; // 必须开启!否则每次都要手动启动 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; // 软件启动,但启动后自动连续 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; if (HAL_ADC_Init(&hadc1) != HAL_OK) { Error_Handler(); // 初始化失败直接卡死 } // 配置ADC通道(假设用PA0) sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_55CYCLES_5; // 采样时间足够长,适配一般传感器 if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) { Error_Handler(); }注意这里ContinuousConvMode = ENABLE是灵魂——它让ADC在一次HAL_ADC_Start()之后,就进入“采样→转换→存入DR→触发DMA”的无限循环。如果设为DISABLE,每采一次就要调一次HAL_ADC_Start(),主频再高也扛不住毫秒级调度开销。
2.2 DMA配置:双缓冲+循环模式,为printf腾出CPU时间
DMA是本工程的“搬运工”,它必须做到两点:不抢CPU、不丢数据。因此采用双缓冲(Double Buffer)+ 循环模式(Circular Mode),并启用半传输中断(HT)和传输完成中断(TC)。缓冲区大小设为1024字节(即512个16位ADC值),足够覆盖100ms内1kHz采样率的数据。
// DMA初始化(在adc_dma.c中) hdma_adc1.Instance = DMA1_Channel1; hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不增(ADC_DR固定地址) hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增 hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; // 16位对齐 hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode = DMA_CIRCULAR; // 关键!循环模式,自动回绕 hdma_adc1.Init.Priority = DMA_PRIORITY_HIGH; if (HAL_DMA_Init(&hdma_adc1) != HAL_OK) { Error_Handler(); } // 绑定DMA到ADC __HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1); // 启动ADC+DMA联合工作 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE, DMA_PERIPH_TO_MEMORY, DMA_PINC_DISABLE);adc_buffer是一个全局uint16_t adc_buffer[ADC_BUFFER_SIZE]数组,被DMA按地址顺序持续写入。由于是DMA_CIRCULAR模式,当写满1024字节后,DMA自动从头开始覆盖——这正是我们做实时数据流处理的基础。而HAL_ADC_Start_DMA()的调用,才是真正把ADC和DMA“焊死”在一起的动作。
2.3 Printf重定向:环形缓冲区+DMA发送,告别阻塞式串口
传统printf重定向到串口,常采用轮询发送(while(!HAL_UART_GetState())),一打几百字节就卡住整个系统。本工程彻底抛弃轮询,改用环形缓冲区(Ring Buffer)+ UART DMA发送 + 空闲中断(IDLE)检测组合拳。usart_printf.c中定义了一个256字节的环形缓冲区:
#define USART_TX_BUFFER_SIZE 256 static uint8_t usart_tx_buffer[USART_TX_BUFFER_SIZE]; static uint16_t tx_head = 0; static uint16_t tx_tail = 0; // _write函数:标准库printf调用入口 int _write(int fd, char *ptr, int len) { if (fd != 1 && fd != 2) return -1; // 只处理stdout/stderr for (int i = 0; i < len; i++) { // 环形缓冲区入队:先检查是否满 uint16_t next_head = (tx_head + 1) % USART_TX_BUFFER_SIZE; if (next_head != tx_tail) { // 未满 usart_tx_buffer[tx_head] = ptr[i]; tx_head = next_head; } else { // 缓冲区满,丢弃该字节(或可改为等待,但此处选择丢弃保实时性) break; } } // 尝试触发一次DMA发送(若当前无DMA在传,则启动;若已在传,则无需动作) usart_try_start_dma_tx(); return len; }usart_try_start_dma_tx()函数负责检查当前DMA状态,若空闲则从环形缓冲区取出一段数据(最多128字节)交给DMA发送。而DMA发送完成中断里,会自动检查缓冲区是否还有数据,有则继续发——形成“推拉结合”的平滑输出流。这才是嵌入式里真正可用的printf。
3. 编译与烧录:Keil MDK-ARM v5.37环境下的零配置落地
本工程为Keil MDK-ARM v5.37原生项目,无需任何插件或额外工具链。所有路径、宏定义、启动文件均已按标准MDK结构组织,开箱即编译。以下步骤经实测(Windows 10 + ST-Link V2)验证有效,跳过任何“可能需要”的猜测步骤,只列必做项。
3.1 Keil工程打开与基础检查
解压工程- ADC+DMA+Printf.zip后,双击ADC_DMA_Printf.uvprojx即可打开工程。首次打开时,Keil会提示“Project requires recompilation due to toolchain change”——忽略,直接点击Rebuild all target files。编译前请确认三点:
- Device设置正确:Project → Options for Target → Device → 选择
STM32F103C8(不是CB或CBT,C8是64KB Flash的主流型号); - Flash算法加载:Utilities → Settings → Flash Download → Add… → 选择
STM32F1xx_Flash_Large.FLM(支持64KB Flash); - 调试器配置:Debug → Settings → Connect → 选择
ST-Link Debugger,Port选SW,Clock设为1000kHz(过高易连不上)。
注意:若使用J-Link,请勿勾选“Use Debug Driver”下的ST-Link选项,否则会报错“Cannot access Target.”;J-Link用户需在Debug页切换为
J-Link,并在Utilities页选择对应J-Link Flash算法。
3.2 编译过程关键日志解读
正常编译输出应包含以下关键行(截取Build Output窗口末尾):
linking... Program Size: Code=12452 RO-data=544 RW-data=320 ZI-data=1248 ".\Objects\ADC_DMA_Printf.axf" - 0 Error(s), 0 Warning(s).其中:
Code=12452:全部可执行代码大小,约12KB,在64KB Flash中占比<20%,留足升级空间;RW-data=320:已初始化的全局变量(如adc_buffer,usart_tx_buffer),占SRAM约320字节;ZI-data=1248:未初始化全局变量(如tx_head,tx_tail等),由启动代码清零。
若出现Error: L6218E: Undefined symbol xxx,90%是adc_dma.c或usart_printf.c未被添加进Target的Source Group中——右键Target → Add Group → 命名“Driver”,再右键该Group → Add Files to Group,确保两个.c文件已勾选。
3.3 烧录与串口验证:三步确认链路畅通
烧录前务必接好硬件:
- PA0(ADC1_IN0)悬空或接一个10kΩ电位器(中心脚接PA0,两端分别接3.3V和GND);
- PA9(USART1_TX)接USB转TTL模块的RX引脚;
- GND共地;
- ST-Link的SWDIO/SWCLK接MCU对应引脚。
烧录步骤:
- 点击Keil工具栏
Load按钮(或Ctrl+L),Keil自动调用ST-Link驱动将.axf烧入Flash; - 烧录成功后,点击
Start/Stop Debug Session(或Ctrl+F5)进入调试; - 立即打开串口助手(推荐XCOM或SSCOM),波特率设为
115200,数据位8,停止位1,无校验,无流控。
此时串口应稳定输出类似内容:
ADC Start OK. V: 0.002V @ 0ms V: 0.003V @ 1ms V: 0.004V @ 2ms ...提示:若首行
ADC Start OK.不出现,说明main()中HAL_ADC_Start_DMA()调用失败,大概率是PA0未配置为模拟输入模式——检查main.c中MX_GPIO_Init()后是否漏掉了HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);(此句实际无效,重点在GPIO_MODE_ANALOG);正确做法是在MX_GPIO_Init()里找到PA0配置段,确认GPIO_InitStruct.Mode = GPIO_MODE_ANALOG;。
4. 常见问题排查:那些让你怀疑人生却只需改一行代码的玄学翻车现场
嵌入式开发最折磨人的,往往不是大架构错误,而是某个寄存器位没置对、某个宏定义少了个L、某个中断没使能……这些坑我都在真实板子上踩过,血泪整理成以下5条高频翻车点,按“现象→原因→解决”直给答案,不绕弯。
4.1 现象:串口完全无输出,或只输出乱码(如~~~)
- 原因:USART1时钟未使能,或GPIOA时钟未使能,或PA9复用功能未开启。
Keil编译无报错,但HAL_UART_Init()内部__HAL_RCC_USART1_CLK_ENABLE()和__HAL_RCC_GPIOA_CLK_ENABLE()没执行,导致UART外设根本没电。 - 解决:打开
stm32f1xx_hal_conf.h,确认以下两行未被注释:
并在#define HAL_MODULE_ENABLED #define HAL_UART_MODULE_ENABLEDmain.c的SystemClock_Config()之后、MX_GPIO_Init()之前,手动加一句:__HAL_RCC_GPIOA_CLK_ENABLE(); // 确保GPIOA时钟先开 __HAL_RCC_USART1_CLK_ENABLE(); // 再开USART1时钟
4.2 现象:ADC值恒为0或恒为0xFFF(4095)
- 原因:ADC校准未执行,或参考电压未稳定。F1系列ADC上电后必须校准,否则读数严重偏移。
- 解决:在
MX_ADC1_Init()函数末尾HAL_ADC_Init(&hadc1)之后,立即插入校准代码:HAL_ADCEx_Calibration_Start(&hadc1); // 必须在Start_DMA之前! HAL_ADC_Start(&hadc1); // 启动ADC(为校准准备) HAL_Delay(1); // 等待校准完成(实际需1ms以上) HAL_ADC_Stop(&hadc1); // 校准完停掉,再启DMA注意:
HAL_ADCEx_Calibration_Start()是阻塞函数,必须在HAL_ADC_Start_DMA()之前调用,否则DMA已启动,ADC处于忙状态,校准失败。
4.3 现象:串口输出卡顿,每隔1秒才刷一次,且数值跳变剧烈
- 原因:
printf格式化字符串中用了%f但未链接浮点运算库,导致printf陷入死循环。Keil默认不链接fplib,%f会调用未定义的_printf_float。 - 解决:Project → Options for Target → Target → Use MicroLIB(勾选);
或更推荐:改用整数运算,将ADC值转电压的公式写成:
此写法完全规避浮点,编译后代码体积小、执行快、无依赖。float voltage = (float)adc_value * 3.3f / 4095.0f; // 但此行仍需浮点库 // 改为整数近似: uint32_t mv = (adc_value * 3300 + 2047) / 4095; // +2047是四舍五入补偿 printf("V: %d.%03dV\r\n", mv/1000, mv%1000);
4.4 现象:DMA缓冲区数据全为0,或只有前几个值有效
- 原因:DMA内存地址未按半字(16位)对齐,或ADC数据寄存器地址读取方式错误。
HAL_ADC_Start_DMA()第二个参数必须是uint32_t*类型指针,但adc_buffer是uint16_t[],直接强转会导致地址错位。 - 解决:声明缓冲区时强制16位对齐,并用正确类型传参:
若用// 在adc_dma.c顶部声明(非栈上!必须全局) __align(2) uint16_t adc_buffer[ADC_BUFFER_SIZE]; // __align(2)确保2字节对齐 // 启动DMA时: HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE, DMA_PERIPH_TO_MEMORY, DMA_PINC_DISABLE);__align(4)(4字节对齐)更稳妥,尤其当ADC_BUFFER_SIZE为奇数时。
4.5 现象:程序运行几分钟后突然死机,串口停发,但LED还在闪
- 原因:环形缓冲区溢出未处理,
tx_head和tx_tail在长时间高频率printf下发生整数溢出,导致tx_head == tx_tail永远为真,_write函数不再入队,但usart_try_start_dma_tx()仍在不断尝试发送空数据,最终耗尽CPU。 - 解决:在
_write函数入队前,增加溢出保护:
同时,在// 替换原入队逻辑 uint16_t next_head = (tx_head + 1) % USART_TX_BUFFER_SIZE; if (next_head != tx_tail) { usart_tx_buffer[tx_head] = ptr[i]; tx_head = next_head; } else { // 缓冲区满,丢弃并记录丢包数(可选) static uint32_t drop_count = 0; drop_count++; break; }usart_try_start_dma_tx()中,发送前检查tx_head != tx_tail,避免空发送。
5. 核心模块代码逐行解析:ADC采样值如何变成带时间戳的电压字符串
现在我们聚焦最核心的一段逻辑:从DMA缓冲区里取出最新ADC值,转换为电压,加上毫秒级时间戳,再通过printf输出。这段代码位于adc_dma.c的HAL_ADC_ConvCpltCallback()回调函数中(传输完成中断触发),是整个数据流的“心脏起搏点”。
5.1 时间戳生成:SysTick微秒计数器的精准截取
工程中不依赖RTC(太重),也不用DWT(调试寄存器,量产不可靠),而是用SysTick定时器的当前计数值反推毫秒级时间戳。delay.c中已实现HAL_GetTick()的底层替换:
// delay.c 中 extern __IO uint32_t uwTick; uint32_t HAL_GetTick(void) { return uwTick; } // SysTick_Handler 中(startup_stm32f103xb.s已配置为1ms中断) void SysTick_Handler(void) { HAL_IncTick(); }但在HAL_ADC_ConvCpltCallback()中,我们不需要1ms精度,需要更高——于是直接读取SysTick的VAL寄存器:
// adc_dma.c 中 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc->Instance == ADC1) { // 获取当前SysTick计数值(24位倒计时,重载值为SystemCoreClock/1000) uint32_t val = SysTick->VAL; uint32_t reload = SysTick->LOAD; // 计算本次中断发生的毫秒时刻(相对启动后) uint32_t ms_now = uwTick * 1000 + (reload - val) * 1000 / reload; // 取DMA缓冲区最后一个有效值(因是循环模式,最新值在buffer末尾附近) // 注意:ADC_BUFFER_SIZE = 1024,DMA每次搬1024字节,所以最新值在buffer[1023] uint16_t latest_adc = adc_buffer[ADC_BUFFER_SIZE - 1]; // 转电压:3.3V参考,12位精度 uint32_t mv = (latest_adc * 3300 + 2047) / 4095; // 四舍五入到毫伏 // 格式化输出(注意:此处printf必须是非阻塞的!已由usart_printf.c保障) printf("V: %d.%03dV @ %dms\r\n", mv/1000, mv%1000, ms_now); } }这段代码的关键在于ms_now的计算。uwTick是HAL库维护的毫秒计数器(每1ms加1),SysTick->VAL是当前倒计时剩余值,SysTick->LOAD是重载值(本工程为71999,对应72MHz/1000Hz)。(reload - val)即本次1ms周期内已过去多少个SysTick滴答,乘以1000/reload即换算为微秒,再除以1000得毫秒小数部分。最终ms_now精度可达10微秒级,远超一般传感器需求。
5.2 ADC值选取策略:为什么取buffer[1023]而不是buffer[0]?
DMA配置为DMA_CIRCULAR,且HAL_ADC_Start_DMA()启动后,DMA会持续将ADC_DR的值写入adc_buffer[0]→adc_buffer[1]→…→adc_buffer[1023]→adc_buffer[0]。当HAL_ADC_ConvCpltCallback()被触发时,意味着刚刚完成了一次1024字节的搬运,此时adc_buffer[1023]正是本次搬运的最后一个值,也就是距离当前时刻最近的ADC采样点。若取buffer[0],那可能是1024次采样前的老数据,延迟高达1024ms(按1kHz采样率),完全失去实时性。
5.3 电压转换的整数魔法:避开浮点,精度不妥协
mv = (latest_adc * 3300 + 2047) / 4095这行是精髓。它实现了:
- 输入:
latest_adc∈ [0, 4095] - 输出:
mv∈ [0, 3300] 毫伏,且四舍五入(+2047是4095/2,标准整数四舍五入技巧) - 无分支、无浮点、单条ARM指令可完成(
UMULL+UDIV,Cortex-M3支持)
验证:当latest_adc = 2048(理论中点),mv = (2048*3300 + 2047)/4095 = (6758400 + 2047)/4095 = 6760447/4095 ≈ 1651,即1.651V,完美匹配3.3V/2。
提示:若你的参考电压不是3.3V(如用3.0V稳压芯片),只需改
3300为3000,其余不变。这种硬编码方式比动态读取VREFINT更可靠,因为VREFINT本身也有±2%误差。
6. 二次开发改造指南:从“能跑”到“为你所用”的5个实战技巧
这个工程的价值,不在于它现在能做什么,而在于它为你铺好了哪几条可扩展的路。我把它用在三个不同项目里:某高校传感器数据采集终端、某工业设备温度监控模块、某学生创新赛的智能小车电池电压监测单元。每一次改造,我都遵循一个铁律:不动核心数据流骨架,只在接口处插拔功能模块。下面分享5个最常用、最安全、效果最立竿见影的改造技巧,附真实代码片段和验证方法。
6.1 技巧一:添加多通道ADC采样,不改DMA,只增配置
原始工程只采PA0(ADC1_IN0)。若需同时采PA0(电压)、PA1(电流)、PA2(温度),无需新增DMA通道,只需开启ADC扫描模式。修改adc_dma.c中ADC初始化部分:
// 修改前(单通道) hadc1.Init.ScanConvMode = DISABLE; sConfig.Rank = 1; sConfig.Channel = ADC_CHANNEL_0; // 修改后(三通道扫描) hadc1.Init.ScanConvMode = ENABLE; // 关键!开启扫描 hadc1.Init.NbrOfConversion = 3; // 总共3个通道 // 配置通道0(PA0) sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_55CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); // 配置通道1(PA1) sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = 2; HAL_ADC_ConfigChannel(&hadc1, &sConfig); // 配置通道2(PA2) sConfig.Channel = ADC_CHANNEL_2; sConfig.Rank = 3; HAL_ADC_ConfigChannel(&hadc1, &sConfig);DMA缓冲区adc_buffer仍为uint16_t[1024],但数据排列变为:[CH0_0, CH1_0, CH2_0, CH0_1, CH1_1, CH2_1, ...]。在HAL_ADC_ConvCpltCallback()中解析时,按索引取值即可:
// 取第i组三通道数据(i从0到340,因1024/3≈341) uint16_t idx = i * 3; uint16_t volt = adc_buffer[idx + 0]; // PA0 uint16_t curr = adc_buffer[idx + 1]; // PA1 uint16_t temp = adc_buffer[idx + 2]; // PA2验证方法:用万用表分别测PA0、PA1、PA2电压,串口输出应同步显示三组值,且彼此独立不串扰。若某通道值异常,优先检查该通道GPIO是否配置为
ANALOG模式。
6.2 技巧二:将printf输出重定向到SD卡,保留原始串口用于调试
很多项目需要长期记录数据,但串口速率有限。本工程的环形缓冲区设计天然支持多输出端。只需在usart_printf.c中,将_write函数拆分为两个独立缓冲区:
// 新增SD卡缓冲区 #define SD_BUFFER_SIZE 4096 static uint8_t sd_buffer[SD_BUFFER_SIZE]; static uint16_t sd_head = 0; static uint16_t sd_tail = 0; // 修改_write:根据fd决定写入哪个缓冲区 int _write(int fd, char *ptr, int len) { if (fd == 1) { // stdout -> 串口 // 原有usart_tx_buffer逻辑 } else if (fd == 3) { // 自定义fd=3 -> SD卡 for (int i = 0; i < len; i++) { uint16_t next = (sd_head + 1) % SD_BUFFER_SIZE; if (next != sd_tail) { sd_buffer[sd_head] = ptr[i]; sd_head = next; } } sd_try_flush(); // 新增函数:当缓冲区满或遇\r\n时,写入SD卡文件 } return len; }然后在主循环中定期调用sd_try_flush(),用FatFS库将sd_buffer内容追加写入data.log。这样,printf("V:%d\r\n", volt);走串口,fdprintf(3, "LOG:%d,%d,%d\r\n", volt,curr,temp);走SD卡,互不干扰。
6.3 技巧三:用ADC值触发事件,比如电压低于阈值点亮LED
这是最典型的嵌入式控制逻辑。不要在HAL_ADC_ConvCpltCallback()里直接操作GPIO(中断里不宜耗时),而是设一个标志位+主循环轮询:
// adc_dma.c 全局变量 volatile uint8_t low_voltage_flag = 0; // 在HAL_ADC_ConvCpltCallback()中 if (mv < 2800) { // 2.8V阈值 low_voltage_flag = 1; } else { low_voltage_flag = 0; } // main.c 主循环中 while (1) { if (low_voltage_flag) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); // 点亮LED } else { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); } HAL_Delay(10); // 10ms去抖 }注意:
low_voltage_flag必须声明为volatile,否则编译器优化可能将其缓存在寄存器,导致主循环永远读不到变化。
6.4 技巧四:添加CRC校验,确保ADC数据在传输中不被干扰
工业现场常有EMI干扰,导致DMA缓冲区个别字节翻转。可在HAL_ADC_ConvCpltCallback()中,对本次搬运的1024字节计算CRC16,并通过串口一并输出:
// 引入CRC计算函数(可从STM32 HAL库crc.c复制) uint16_t crc16(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; } // 在回调中 uint16_t crc = crc16((uint8_t*)adc_buffer, ADC_BUFFER_SIZE * sizeof(uint16_t)); printf("CRC:0x%04X\r\n", crc);接收端(如PC上位机)收到数据后,自行计算CRC并与CRC:字段比对,不一致则丢弃整包。这是成本最低的可靠性增强手段。
6.5 技巧五:移植到其他MCU平台,比如GD32F103
本工程HAL库风格通用,移植只需三步:
- 替换
Drivers/STM32F1xx_HAL_Driver/为GD32F10x的HAL库(官方提供); - 修改
system_gd32f10x.c中的时钟配置(GD32 PLL倍频系数与STM32不同); - 将
startup_gd32f10x.s替换原启动文件,并在Keil Device中选择GD32F103C8。
最大的坑是GD32的ADC校准函数名不同:HAL_ADCEx_Calibration_Start()在GD32中为HAL_ADCEx_Calibration_Start(&hadc, ADC_CALIBRATION_DIFFERENTIAL),需传入第二个参数。填ADC_CALIBRATION_DIFFERENTIAL即可。
从那以后我每次接到新MCU平台任务,第一件事就是拿这个ADC+DMA+Printf工程当“探针”:先让它在新板子上跑通,再逐步叠加业务逻辑。它就像一把嵌入式手术刀,切开任何新芯片的第一层皮,暴露最真实的外设行为。希望帮到你。
本文还有配套的精品资源,点击获取