news 2026/11/5 9:18:55

STM32 ADC+DMA+Printf全链路实战工程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 ADC+DMA+Printf全链路实战工程

简介:本资源是一份面向嵌入式初学者与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。编译前请确认三点:

  1. Device设置正确:Project → Options for Target → Device → 选择STM32F103C8(不是CB或CBT,C8是64KB Flash的主流型号);
  2. Flash算法加载:Utilities → Settings → Flash Download → Add… → 选择STM32F1xx_Flash_Large.FLM(支持64KB Flash);
  3. 调试器配置: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对应引脚。

烧录步骤:

  1. 点击Keil工具栏Load按钮(或Ctrl+L),Keil自动调用ST-Link驱动将.axf烧入Flash;
  2. 烧录成功后,点击Start/Stop Debug Session(或Ctrl+F5)进入调试;
  3. 立即打开串口助手(推荐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_ENABLED
    并在main.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库风格通用,移植只需三步:

  1. 替换Drivers/STM32F1xx_HAL_Driver/为GD32F10x的HAL库(官方提供);
  2. 修改system_gd32f10x.c中的时钟配置(GD32 PLL倍频系数与STM32不同);
  3. 将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工程当“探针”:先让它在新板子上跑通,再逐步叠加业务逻辑。它就像一把嵌入式手术刀,切开任何新芯片的第一层皮,暴露最真实的外设行为。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 9:17:22

DE430星历详解:MATLAB中实现太阳系厘米级轨道计算

简介&#xff1a;本资源是面向航天工程、天文计算与MATLAB仿真方向的科研人员及高年级本科生/研究生的DE430高精度星历数据处理工具包&#xff0c;解决太阳系天体位置精确建模、航天器轨道设计与天文事件预测等核心问题。压缩包共11个文件&#xff08;4个MATLAB源码文件用于读取…

作者头像 李华
网站建设 2026/11/5 9:04:36

File、IO流

File用于操作文件&#xff0c;IO流用于读写数据1.File文件对象常用方法package com.popo.filedemo;import java.io.File; import java.io.IOException;public class FileDemo1 {public static void main(String[] args) throws Exception {// 文件对象常用方法File f1 new Fil…

作者头像 李华
网站建设 2026/11/5 8:43:18

【Java基础知识】进制转换

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/5 8:39:53

S7-1200外部中断实战:创建、分离与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华