4. 代码生成与工程结构分析:MDK-ARM 工程、main.c、adc.c、dma.c 解读
好,咱们进入第四章。前面几章我们把原理和配置讲透了,这一章要落地了——直接打开 MDK-ARM 工程,一行一行看代码。
很多初学者喜欢把代码全塞在 main.c 里,觉得这样省事。我刚开始做项目时也这么干,结果一个文件几千行,找个 bug 找到崩溃。后来我养成了一个习惯:按功能模块拆文件。ADC 的归 adc.c,DMA 的归 dma.c,main.c 只做初始化调度。你想想看,这样定位问题是不是快多了?
本章核心目标:理解 MDK-ARM 工程的文件组织方式,掌握 HAL 库初始化流程,并能独立解读 main.c、adc.c、dma.c 三个核心文件的结构。
4.1 MDK-ARM 工程结构总览
我们先看一眼工程目录长什么样。一个标准的 STM32 MDK-ARM 工程,通常包含以下几个关键部分:
| 文件夹/文件 | 作用 | 个人建议 |
|---|---|---|
| User/ | 存放 main.c、stm32f1xx_it.c、stm32f1xx_hal_msp.c | 这是你主要写代码的地方 |
| Drivers/STM32F1xx_HAL_Driver/ | HAL 库源码,如 stm32f1xx_hal_adc.c、stm32f1xx_hal_dma.c | 不要修改!不要修改!不要修改! |
| Drivers/CMSIS/ | 内核相关文件,启动文件、系统时钟配置 | 一般不动,除非换芯片 |
| MDK-ARM/ | 工程文件 .uvprojx、编译中间文件 | 备份这个文件夹,重装系统不慌 |
我习惯在 User 目录下再建一个 App/ 文件夹,把 adc.c、dma.c 这些自己写的模块放进去。这样工程结构更清晰,找文件不用翻半天。
4.2 main.c 解读:初始化调度中心
main.c 是整个程序的入口。说白了,它就是个调度员——把各个模块初始化好,然后进入死循环等待事件。我们来看一段典型的多通道 ADC + DMA 的 main.c 代码:
/* main.c - 多通道ADC DMA采样主程序 */
#include "main.h"
#include "adc.h"
#include "dma.h"
/* 全局变量:存储ADC采样结果 */
__IO uint16_t adc_values[3] = {0}; // 3个通道
int main(void)
{
/* 1. HAL库初始化 */
HAL_Init();
/* 2. 系统时钟配置 */
SystemClock_Config();
/* 3. 外设初始化 */
MX_ADC1_Init(); // ADC初始化
MX_DMA_Init(); // DMA初始化
/* 4. 启动ADC+DMA */
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_values, 3);
/* 5. 主循环 */
while (1)
{
/* 这里可以处理 adc_values 中的数据 */
/* 比如:计算平均值、触发控制逻辑等 */
}
}
注意看第 4 步。我见过很多新手在这里犯错:先启动 DMA,再启动 ADC。结果 DMA 没准备好,ADC 数据来了没人搬,丢数据。正确的顺序是——先初始化 DMA,再启动 ADC 的 DMA 传输。HAL_ADC_Start_DMA 这个函数内部已经帮你做了这件事,所以直接用就行。
💡 个人经验:我习惯在 main.c 里只放初始化流程和主循环框架。具体的初始化函数实现,比如 MX_ADC1_Init(),我会放到 adc.c 里。这样 main.c 看起来清爽,别人接手你的代码也容易看懂。
4.3 adc.c 解读:ADC 配置的细节
adc.c 里主要做两件事:配置 ADC 的工作参数,以及实现初始化函数。我们来看核心代码:
/* adc.c - ADC多通道配置 */
#include "adc.h"
ADC_HandleTypeDef hadc1;
void MX_ADC1_Init(void)
{
ADC_ChannelConfTypeDef sConfig = {0};
/* 使能ADC时钟 */
__HAL_RCC_ADC1_CLK_ENABLE();
/* ADC句柄配置 */
hadc1.Instance = ADC1;
hadc1.Init.ScanConvMode = ENABLE; // 扫描模式
hadc1.Init.ContinuousConvMode = ENABLE; // 连续转换
hadc1.Init.DiscontinuousConvMode = DISABLE;
hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; // 软件触发
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; // 右对齐
hadc1.Init.NbrOfConversion = 3; // 3个通道
HAL_ADC_Init(&hadc1);
/* 配置通道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);
}
这里有几个关键点,我重点说一下:
- ScanConvMode = ENABLE:必须开启,否则只采一个通道。我遇到过有人忘了开,结果三个通道采出来的数据一模一样。
- ContinuousConvMode = ENABLE:连续转换模式。如果你只想采一次,就设 DISABLE,然后手动触发。
- NbrOfConversion = 3:告诉 ADC 要采几个通道。这个值必须和实际配置的通道数一致,否则会漏采或溢出。
- SamplingTime:采样时间。通道阻抗高的时候要加大这个值,不然采样值会偏小。我曾经在接高阻传感器时吃过这个亏,数据一直不对,后来把采样时间从 1.5 周期改成 55.5 周期,问题就解决了。
4.4 dma.c 解读:数据搬运工
DMA 的配置相对简单,但细节决定成败。来看代码:
/* dma.c - DMA配置 */
#include "dma.h"
DMA_HandleTypeDef hdma_adc1;
void MX_DMA_Init(void)
{
/* 使能DMA1时钟 */
__HAL_RCC_DMA1_CLK_ENABLE();
/* DMA句柄配置 */
hdma_adc1.Instance = DMA1_Channel1;
hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; // 外设到内存
hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不增
hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; // 16位
hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; // 16位
hdma_adc1.Init.Mode = DMA_CIRCULAR; // 循环模式
HAL_DMA_Init(&hdma_adc1);
/* 将DMA句柄与ADC句柄关联 */
__HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1);
}
这里有个容易踩的坑:PeriphInc 和 MemInc 的设置。ADC 的数据寄存器地址是固定的,所以外设地址不递增。但内存地址必须递增,否则每次 DMA 传输都会覆盖同一个位置,你只能看到最后一个通道的数据。
⚠️ 我曾经踩过的坑:有一次我忘了设 MemInc = ENABLE,结果三个通道的数据全挤在 adc_values[0] 里,其他两个数组元素永远是 0。查了半天才发现是 DMA 配置的问题。所以,多通道采样时,一定要确认 MemInc 是使能的。
另外,Mode = DMA_CIRCULAR 表示循环模式。DMA 传输完一轮(3 个通道)后,会自动从头开始。这样你只需要启动一次,ADC 就会不停地采,DMA 不停地搬,数据一直在更新。如果你只想采一轮,就设 DMA_NORMAL。
4.5 HAL 库初始化流程全解析
很多初学者觉得 HAL 库初始化很神秘,其实就是一套固定的流程。我把它总结成 4 个步骤:
- HAL_Init():初始化 HAL 库内部的一些东西,比如滴答定时器、中断优先级分组。这个函数必须在所有外设初始化之前调用。
- 系统时钟配置:设置 HSE、PLL、系统时钟频率。一般用 CubeMX 生成的代码,不用自己写。
- 外设初始化:调用 MX_xxx_Init() 函数,配置具体外设的参数。这里会调用 HAL_xxx_Init(),而 HAL_xxx_Init() 内部又会调用 HAL_xxx_MspInit() 来做 GPIO、时钟、中断等底层配置。
- 启动外设:调用 HAL_xxx_Start() 或 HAL_xxx_Start_DMA(),让外设开始工作。
这里有个重要的回调机制:HAL_ADC_MspInit()。这个函数在 HAL_ADC_Init() 内部被调用,专门用来配置 GPIO、使能时钟、配置中断和 DMA。我习惯把 GPIO 的初始化放在这个函数里,而不是写在 adc.c 的 MX_ADC1_Init() 中。这样代码更符合 HAL 库的设计哲学。
📋 初始化流程总结
| 步骤 | 函数 | 作用 |
|---|---|---|
| 1 | HAL_Init() | HAL 库内核初始化 |
| 2 | SystemClock_Config() | 系统时钟配置 |
| 3 | MX_ADC1_Init() → HAL_ADC_Init() → HAL_ADC_MspInit() | ADC 外设及底层初始化 |
| 4 | MX_DMA_Init() → HAL_DMA_Init() | DMA 初始化 |
| 5 | HAL_ADC_Start_DMA() | 启动 ADC + DMA 传输 |
4.6 工程结构的最佳实践
最后,我分享几个工程组织的小技巧,都是实战中总结出来的:
- 头文件统一管理:在 main.h 里包含所有需要用到的头文件,其他 .c 文件只需要 #include "main.h" 就行。这样改一个地方,所有文件都生效。
- 全局变量集中声明:把 adc_values 这样的全局变量放在 main.h 里,用 extern 声明。不要在多个 .c 文件里重复定义,否则链接时会报错。
- 函数命名规范:初始化函数用 MX_ 前缀(CubeMX 生成的代码风格),用户自定义函数用你自己习惯的前缀,比如 APP_、USER_。这样一眼就能看出哪些是生成的,哪些是自己写的。
- 版本控制:MDK-ARM 工程里有很多临时文件(.o、.d、.crf 等),提交 Git 时记得加 .gitignore,只保留源码和工程配置文件。
嗯,这一章的内容就到这里。代码结构清楚了,初始化流程理解了,后面调试的时候你就能快速定位问题——是 ADC 配置错了,还是 DMA 没搬数据,看一眼代码就能判断个八九不离十。