ADC — ADC 软件触发轮询
固件库: GD32F30x_Firmware_Library V3.0.3
芯片: GD32F307 (GD32307C-EVAL-V1.1 开发板)
源文件: GD32F30x_Firmware_Library_V3.0.3/Examples/ADC/ADC0_software_trigger_routine_channel_polling/main.c
功能简介
这是 ADC 的入门第一课,也是最容易看懂的一种 ADC 用法。
程序用 ADC0 依次采集 4 路模拟电压(PA1、PA2、PA3、PA4),把结果存到 adc_value[4] 数组里,然后不停地重复。
标题里三个关键词,拆开看:
| 关键词 |
含义 |
| 软件触发 |
转换不是由定时器或外部信号启动的,而是我们在程序里调用一个函数手动"发令" |
| 常规通道 |
用的是常规组(Routine),相对于优先级更高的注入组(Inserted) |
| 轮询 |
发令之后,CPU 在 while 里死等标志位,等到了就读数据(不用中断、不用 DMA) |
为什么先学这个? 因为它把 ADC 的完整流程一步不省地摊开在你眼前:
1
|
配时钟 → 配引脚 → 配 ADC → 校准 → 选通道 → 发令 → 等完成 → 读数
|
后面所有更高级的用法(DMA 自动搬运、定时器触发、双 ADC 并行),都只是把上面某几步换成硬件自动完成而已。先把手动流程走通,再去理解自动化才不会一头雾水。
硬件准备
| 器材 |
说明 |
| GD32307C-EVAL-V1.1 开发板 |
或任意 GD32F30x 板子 |
| 电位器(可调电阻)1~4 个 |
10kΩ 卧式电位器,几毛钱一个 |
| 杜邦线 |
接线用 |
| 调试器(GD-Link / J-Link) |
需要用 Watch 窗口看变量 |
使用的引脚
| ADC 通道 |
引脚 |
ADC_CHANNEL_1 |
PA1 |
ADC_CHANNEL_2 |
PA2 |
ADC_CHANNEL_3 |
PA3 |
ADC_CHANNEL_4 |
PA4 |
通道号和引脚号的对应关系:GD32F30x 上 ADC 通道 07 对应 PA0PA7,通道 89 对应 PB0PB1,通道 1015 对应 PC0PC5。所以 ADC_CHANNEL_1 = PA1,规律很好记。
电位器接线
1
2
3
4
5
6
7
8
9
|
3.3V
│
┌┴┐
│ │ ← 电位器(10kΩ)
│ ├──────→ PA1(中间的滑动端接 ADC 引脚)
│ │
└┬┘
│
GND
|
旋转旋钮,中间抽头的电压就在 0V ~ 3.3V 之间连续变化,ADC 读数在 0 ~ 4095 之间跟着变。这是验证 ADC 是否正常工作的最直观办法。
如果暂时没有电位器,也可以:
- 直接把引脚接 GND → 读数应该接近 0
- 直接把引脚接 3.3V → 读数应该接近 4095
- 什么都不接(悬空)→ 读数会乱跳,这是正常现象(悬空引脚电压不确定)
完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
|
#include "gd32f30x.h"
#include "systick.h"
#include <stdio.h>
#include "gd32f307c_eval.h"
/* 存放 4 个通道的采样结果
__IO 是 volatile 的宏,告诉编译器"这个变量可能被意外修改,
不要做优化"。这样调试器的 Watch 窗口才能看到实时变化 */
__IO uint16_t adc_value[4];
void rcu_config(void);
void gpio_config(void);
void adc_config(void);
uint16_t adc_channel_sample(uint8_t channel);
/*!
\brief 主函数
\param[in] 无
\param[out] 无
\retval 无
*/
int main(void)
{
/* 1. 时钟配置:开 GPIOA、ADC0 时钟,并给 ADC 分频 */
rcu_config();
/* 2. SysTick 配置,为了后面 delay_1ms 能用 */
systick_config();
/* 3. GPIO 配置:把 4 个引脚配成模拟输入 */
gpio_config();
/* 4. ADC 配置:模式、对齐、触发源、使能、校准 */
adc_config();
while(1){
/* 依次采集 4 个通道,每采完一个立刻存进数组
注意:必须一个一个来,因为常规组只有一个数据寄存器 */
adc_value[0] = adc_channel_sample(ADC_CHANNEL_1); /* PA1 */
adc_value[1] = adc_channel_sample(ADC_CHANNEL_2); /* PA2 */
adc_value[2] = adc_channel_sample(ADC_CHANNEL_3); /* PA3 */
adc_value[3] = adc_channel_sample(ADC_CHANNEL_4); /* PA4 */
}
}
/*!
\brief 配置各个系统时钟
\param[in] 无
\param[out] 无
\retval 无
*/
void rcu_config(void)
{
/* 开 GPIOA 时钟 —— PA1~PA4 属于 GPIOA */
rcu_periph_clock_enable(RCU_GPIOA);
/* 开 ADC0 外设时钟 */
rcu_periph_clock_enable(RCU_ADC0);
/* 配置 ADC 时钟分频:APB2(120MHz) / 6 = 20MHz
ADC 时钟不能太高,手册推荐不超过 14MHz */
rcu_adc_clock_config(RCU_CKADC_CKAPB2_DIV6);
}
/*!
\brief 配置 GPIO
\param[in] 无
\param[out] 无
\retval 无
*/
void gpio_config(void)
{
/* 把 PA1~PA4 全部配成 AIN(Analog IN,模拟输入)模式
这个模式会断开引脚上的数字输入电路和上下拉电阻,
让引脚"纯净"地把电压送给 ADC */
gpio_init(GPIOA, GPIO_MODE_AIN, GPIO_OSPEED_10MHZ, GPIO_PIN_1);
gpio_init(GPIOA, GPIO_MODE_AIN, GPIO_OSPEED_10MHZ, GPIO_PIN_2);
gpio_init(GPIOA, GPIO_MODE_AIN, GPIO_OSPEED_10MHZ, GPIO_PIN_3);
gpio_init(GPIOA, GPIO_MODE_AIN, GPIO_OSPEED_10MHZ, GPIO_PIN_4);
}
/*!
\brief 配置 ADC 外设
\param[in] 无
\param[out] 无
\retval 无
*/
void adc_config(void)
{
/* 独立模式:ADC0 自己单独工作,不和 ADC1 联动 */
adc_mode_config(ADC_MODE_FREE);
/* 数据右对齐:12 位结果放在 16 位寄存器的低 12 位
读出来直接就是 0~4095,不用移位 */
adc_data_alignment_config(ADC0, ADC_DATAALIGN_RIGHT);
/* 常规组序列长度设为 1:一次只转换 1 个通道
因为常规组只有一个数据寄存器,转多个会互相覆盖 */
adc_channel_length_config(ADC0, ADC_ROUTINE_CHANNEL, 1U);
/* 外部触发源设为 NONE:不使用定时器/引脚触发 */
adc_external_trigger_source_config(ADC0, ADC_ROUTINE_CHANNEL,
ADC0_1_2_EXTTRIG_ROUTINE_NONE);
/* 使能外部触发通路 —— 这句必须有!
软件触发在硬件上也走"外部触发"这条路,
只是触发源选成了 NONE(即 SWRCST 软件触发) */
adc_external_trigger_config(ADC0, ADC_ROUTINE_CHANNEL, ENABLE);
/* 使能 ADC0 */
adc_enable(ADC0);
/* 等 ADC 上电稳定(手册要求 tSTAB,至少 1μs,这里给 1ms 足够宽裕) */
delay_1ms(1U);
/* 启动校准,消除内部电容工艺偏差
注意顺序:必须在 adc_enable 之后 */
adc_calibration_enable(ADC0);
}
/*!
\brief 采集指定通道的一次数据
\param[in] channel: 要采集的 ADC 通道号
\param[out] 无
\retval 该通道的 12 位转换结果(0~4095)
*/
uint16_t adc_channel_sample(uint8_t channel)
{
/* 把要采的通道放到常规组序列的第 0 个位置,采样时间 7.5 个周期 */
adc_routine_channel_config(ADC0, 0U, channel, ADC_SAMPLETIME_7POINT5);
/* 发令:软件触发,启动这一次转换 */
adc_software_trigger_enable(ADC0, ADC_ROUTINE_CHANNEL);
/* 死等 EOC(End Of Conversion)标志置位,表示转换完成 */
while(!adc_flag_get(ADC0, ADC_FLAG_EOC));
/* 手动清除 EOC 标志,为下一次转换做准备 */
adc_flag_clear(ADC0, ADC_FLAG_EOC);
/* 读取常规组数据寄存器,返回结果 */
return (adc_routine_data_read(ADC0));
}
|
代码分段讲解
第 1 段:__IO 到底是干什么的
1
|
__IO uint16_t adc_value[4];
|
__IO 在 GD32 头文件里定义为 volatile:
为什么必须加 volatile?
编译器很"聪明",它会做优化。看到 adc_value 这个数组被赋值之后从来没有被读取过,优化器会认为"这些赋值是没用的死代码",直接删掉!结果就是你在调试器 Watch 窗口里看到的永远是 0。
加上 volatile 之后,等于告诉编译器:“这个变量的值可能会被程序之外的东西(调试器、中断、硬件)读写,你老老实实每次都真的去内存里存取,不许优化。”
实战规则:以下三种变量必须加 volatile:
- 会被中断服务函数修改,同时又在主循环里读的全局变量
- 映射到硬件寄存器的指针
- 只用于调试观察的变量(本例的情况)
忘加 volatile 导致的 bug 极其难查 —— 因为 Debug 版本(不优化)跑得好好的,一开 -O2 优化就出问题。
第 2 段:GPIO_MODE_AIN 和其他模式的本质区别
1
|
gpio_init(GPIOA, GPIO_MODE_AIN, GPIO_OSPEED_10MHZ, GPIO_PIN_1);
|
AIN = Analog IN,模拟输入模式。它跟数字输入模式(IN_FLOATING / IPU / IPD)在硬件上完全是两回事:
1
2
3
4
5
6
7
8
|
数字输入模式(IN_FLOATING):
引脚 ──┬── [施密特触发器] ── 数字输入寄存器(读 0 或 1)
└── [上下拉电阻网络] ← 这些电路会影响模拟电压!
└── [ESD 保护]
模拟输入模式(AIN):
引脚 ──┬── [直连] ─────────── ADC 采样开关 ── 采样电容
└── 施密特触发器已关断,上下拉已断开
|
关键点:AIN 模式会关掉施密特触发器和上下拉电阻。这两个东西对数字信号无害,但对模拟测量是灾难:
- 施密特触发器一直在耗电(漏电流),会拉偏被测电压
- 上下拉电阻(约 40kΩ)会和你的信号源形成分压,读数直接错
新手常见错误:配成 GPIO_MODE_IN_FLOATING 也能读到数,但读数会有偏差(尤其信号源内阻大时)。所以ADC 引脚必须配 AIN,没有例外。
至于 GPIO_OSPEED_10MHZ 这个参数在 AIN 模式下是无意义的(引脚根本不输出),填什么都行,官方填 10MHz 只是习惯。
第 3 段:为什么"软件触发"要开"外部触发"
这是本例最容易让人困惑的一处:
1
2
3
|
adc_external_trigger_source_config(ADC0, ADC_ROUTINE_CHANNEL,
ADC0_1_2_EXTTRIG_ROUTINE_NONE);
adc_external_trigger_config(ADC0, ADC_ROUTINE_CHANNEL, ENABLE);
|
明明是软件触发,为什么要配"外部触发源"还要 ENABLE?
原因在硬件设计上:GD32 的 ADC 内部只有一条"触发输入"通道。这条通道可以被连接到:
| 触发源选项 |
实际含义 |
ADC0_1_2_EXTTRIG_ROUTINE_T0_CH0 |
定时器 0 通道 0 事件 |
ADC0_1_2_EXTTRIG_ROUTINE_T2_TRGO |
定时器 2 的 TRGO 事件 |
ADC0_1_2_EXTTRIG_ROUTINE_EXTI_11 |
外部中断线 11 |
ADC0_1_2_EXTTRIG_ROUTINE_NONE |
不接任何硬件源,改由软件位 SWRCST 触发 |
所以逻辑是这样的:
adc_external_trigger_source_config(..., NONE) —— 把多路选择器拨到"软件"档
adc_external_trigger_config(..., ENABLE) —— 打开触发通道的总闸(ETERC 位)
如果第 2 句不写,总闸关着,无论你怎么调 adc_software_trigger_enable() 都不会启动转换。 程序会永远卡死在 while(!adc_flag_get(ADC0, ADC_FLAG_EOC)); 这一行。
这是新手实际会踩的坑。如果你自己精简代码把这句删了,就会遇到"程序卡死、ADC 不动"的现象。
第 4 段:序列长度为什么设成 1
1
|
adc_channel_length_config(ADC0, ADC_ROUTINE_CHANNEL, 1U);
|
“序列”(Sequence)是 ADC 常规组的一个重要概念。你可以配置一个最多 16 长的转换序列,比如:
1
2
3
|
序列位置 0 → 通道 3
序列位置 1 → 通道 7
序列位置 2 → 通道 1
|
配好之后触发一次,ADC 会按序列顺序把这 3 个通道都转一遍。
但问题来了:常规组只有一个 ADC_RDATA 数据寄存器。转完通道 3 结果放进去,接着转通道 7,结果又覆盖进去…… 最后你只能读到最后一个通道的值,前面全丢了。
所以只有两条路:
| 方案 |
做法 |
优缺点 |
| 本例的方案 |
序列长度设 1,每次只转一个通道,转完立刻读走,然后改通道再转 |
简单直观,CPU 全程参与 |
| DMA 方案 |
序列长度设 4,开启扫描模式 + DMA,DMA 在每次 EOC 时自动把结果搬到数组 |
CPU 零参与,效率高,但配置复杂 |
本例用第一种,所以序列长度必须是 1,而且 adc_routine_channel_config() 的第二个参数(序列位置)永远填 0。
实际项目里多通道采集几乎都用 DMA 方案(参考 ADC0_routine_channel_with_DMA 示例)。但先理解本例的手动版本,你才知道 DMA 到底帮你省掉了什么。
第 5 段:一次完整采样的四步
1
2
3
4
5
6
7
8
|
uint16_t adc_channel_sample(uint8_t channel)
{
adc_routine_channel_config(ADC0, 0U, channel, ADC_SAMPLETIME_7POINT5); /* ① 选通道 */
adc_software_trigger_enable(ADC0, ADC_ROUTINE_CHANNEL); /* ② 发令 */
while(!adc_flag_get(ADC0, ADC_FLAG_EOC)); /* ③ 等完成 */
adc_flag_clear(ADC0, ADC_FLAG_EOC); /* ④ 清标志 */
return (adc_routine_data_read(ADC0)); /* ⑤ 读数据 */
}
|
① 选通道 —— adc_routine_channel_config(ADC0, 0U, channel, ADC_SAMPLETIME_7POINT5)
四个参数:ADC 编号、序列位置(这里固定 0)、通道号、采样时间。
每次调用都重新设置一次,所以同一个函数可以采任意通道 —— 这就是函数复用的巧妙之处。
② 发令 —— adc_software_trigger_enable()
内部就是往控制寄存器的 SWRCST 位写 1。硬件看到这一位就开始干活:先接通对应引脚的采样开关给电容充电(持续 7.5 个 ADC 周期),然后断开开关做 12 次逐次逼近比较,最后把结果写进 ADC_RDATA。
③ 等完成 —— while(!adc_flag_get(ADC0, ADC_FLAG_EOC))
EOC = End Of Conversion。硬件转换完成会自动把这一位置 1。
在 20MHz ADC 时钟、7.5 采样时间下,这个等待时间是:
1
|
(7.5 + 12.5) / 20MHz = 1 μs
|
也就是说 CPU 大约白等 1 微秒,采 4 个通道总共 4 微秒。对于点灯级应用完全可以接受。
④ 清标志
有个容易忽略的细节:读 ADC_RDATA 寄存器本身也会自动清除 EOC 标志。所以理论上第④步是多余的。但显式写出来更清晰、更保险(万一你改代码把 read 挪走了呢),是好的编程习惯。
⑤ 读数据 —— adc_routine_data_read(ADC0)
返回的就是 0~4095 的原始码值。
第 6 段:主循环里数据是怎么流动的
1
2
3
4
5
6
|
while(1){
adc_value[0] = adc_channel_sample(ADC_CHANNEL_1);
adc_value[1] = adc_channel_sample(ADC_CHANNEL_2);
adc_value[2] = adc_channel_sample(ADC_CHANNEL_3);
adc_value[3] = adc_channel_sample(ADC_CHANNEL_4);
}
|
时序图(每格约 1μs):
1
2
3
4
|
时间 →
│ 采PA1 │ 采PA2 │ 采PA3 │ 采PA4 │ 采PA1 │ 采PA2 │ ...
↓ ↓ ↓ ↓
[0] [1] [2] [3]
|
注意这个循环转得极快:4 次采样加上函数调用开销,一轮大约 5~10 微秒,也就是每秒刷新 10 万次以上。
这对于测量慢变化的物理量(温度、电位器位置)是巨大的浪费,而且会让读数因为噪声而抖动。实际项目应该加延时并做滤波:
1
2
3
4
5
|
while(1){
adc_value[0] = adc_channel_sample(ADC_CHANNEL_1);
/* ...其他通道... */
delay_1ms(100); /* 每 100ms 采一轮就够了 */
}
|
实验现象
本例程没有任何 LED 或串口输出,必须用调试器观察。
用 Keil 的 Watch 窗口看
- 编译下载后,点击
Debug → Start/Stop Debug Session(或按 Ctrl+F5)。
- 点击
Run(F5)让程序跑起来。
- 打开
View → Watch Windows → Watch 1。
- 在 Watch 窗口里输入
adc_value,回车。
- 展开数组,就能看到 4 个通道的实时值。
- 转动接在 PA1 上的电位器,
adc_value[0] 的数值会跟着变化。
预期看到的数值:
| 电位器位置 |
adc_value[0] |
对应电压 |
| 旋到底(接 GND) |
0 ~ 20 |
约 0V |
| 中间 |
约 2048 |
约 1.65V |
| 旋到顶(接 3.3V) |
4070 ~ 4095 |
约 3.3V |
Keil 小技巧:勾选 View → Periodic Window Update,Watch 窗口才会在程序运行时持续刷新。不勾的话只有暂停时才更新数值,看起来像"数据不变"。
更直观的办法:加串口打印
强烈建议自己改一版加上 printf,看起来爽多了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
#include "gd32f307c_eval.h"
int main(void)
{
rcu_config();
systick_config();
gpio_config();
adc_config();
gd_eval_com_init(EVAL_COM0); /* 初始化调试串口 */
while(1){
adc_value[0] = adc_channel_sample(ADC_CHANNEL_1);
adc_value[1] = adc_channel_sample(ADC_CHANNEL_2);
adc_value[2] = adc_channel_sample(ADC_CHANNEL_3);
adc_value[3] = adc_channel_sample(ADC_CHANNEL_4);
/* 同时打印原始码值和换算后的电压(mV,避免用浮点) */
printf("CH1=%4d(%4dmV) CH2=%4d CH3=%4d CH4=%4d\r\n",
adc_value[0], adc_value[0] * 3300 / 4095,
adc_value[1], adc_value[2], adc_value[3]);
delay_1ms(500);
}
}
|
串口助手输出:
1
2
3
|
CH1=2047(1649mV) CH2= 3 CH3=1024 CH4=4090
CH1=2531(2039mV) CH2= 4 CH3=1023 CH4=4091
CH1=3102(2499mV) CH2= 3 CH3=1025 CH4=4090
|
注意 adc_value[0] * 3300 / 4095 这个写法:先乘后除,全程整数运算,避免了浮点数的开销。这是嵌入式里换算电压的标准技巧。
小心溢出:adc_value 是 uint16_t,最大 4095,乘 3300 得到 13513500,超出了 uint16_t 范围。但因为 C 语言的整数提升规则,表达式会自动按 int(32 位)计算,所以这里是安全的。如果乘的系数更大(比如 33000),就要显式转成 uint32_t。
常见问题
Q1:程序卡死在 while(!adc_flag_get(ADC0, ADC_FLAG_EOC));
这是本例最高频的问题。EOC 永远不置位,说明转换根本没启动。按顺序检查:
-
少了 adc_external_trigger_config(ADC0, ADC_ROUTINE_CHANNEL, ENABLE);
前面详细讲过,这是触发通道的总闸。少了它软件触发无效。这是最常见的原因。
-
忘了 rcu_periph_clock_enable(RCU_ADC0);
外设时钟没开,所有 ADC 寄存器写进去都是无效的。
-
忘了 adc_enable(ADC0);
ADC 没使能,硬件处于关断状态。
-
忘了 rcu_adc_clock_config()
ADC 时钟源没配。虽然有默认值,但显式配置是好习惯。
-
改用了注入组的宏但 flag 还查常规组的
如果你把 ADC_ROUTINE_CHANNEL 改成了 ADC_INSERTED_CHANNEL,那 flag 也要改成 ADC_FLAG_EOIC。
Q2:读数一直是 0 或一直是 4095
读数恒为 0:
- 引脚没接信号,或者接到了 GND
- 引脚模式配错(没配成
GPIO_MODE_AIN)
- 通道号和引脚对不上(比如想采 PA1 但填了
ADC_CHANNEL_0)
- GPIOA 时钟没开
读数恒为 4095:
- 输入电压超过了
VREF+(通常等于 VDDA = 3.3V)。注意 ADC 引脚绝对不能超过 VDD + 0.3V,否则会烧片!
- 引脚接错,接到了 3.3V
Q3:读数不停跳动,跳几十个数
这是 ADC 最典型的"精度"问题,几乎人人会遇到。按性价比从高到低排:
① 加大采样时间(改一行,立竿见影)
1
|
adc_routine_channel_config(ADC0, 0U, channel, ADC_SAMPLETIME_239POINT5);
|
信号源内阻大时(热敏电阻、高阻分压),采样电容充不满,读数就会偏低且不稳。加大采样时间是第一选择。
② 降低 ADC 时钟
1
|
rcu_adc_clock_config(RCU_CKADC_CKAPB2_DIV12); /* 120/12 = 10MHz,符合 ≤14MHz 要求 */
|
原代码用 DIV6 得到 20MHz,已经超出手册推荐值。降到 10MHz 精度会明显改善。
③ 软件滤波(最有效的手段)
最简单的多次平均:
1
2
3
4
5
6
7
8
9
10
11
12
|
uint16_t adc_average_sample(uint8_t channel, uint8_t times)
{
uint32_t sum = 0;
uint8_t i;
for(i = 0; i < times; i++){
sum += adc_channel_sample(channel);
}
return (uint16_t)(sum / times);
}
/* 使用:采 16 次取平均 */
adc_value[0] = adc_average_sample(ADC_CHANNEL_1, 16);
|
更抗突发干扰的去极值平均(掐头去尾法):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
uint16_t adc_filter_sample(uint8_t channel)
{
uint16_t buf[10];
uint16_t max = 0, min = 0xFFFF;
uint32_t sum = 0;
uint8_t i;
/* 连续采 10 次 */
for(i = 0; i < 10; i++){
buf[i] = adc_channel_sample(channel);
sum += buf[i];
if(buf[i] > max) max = buf[i]; /* 记录最大值 */
if(buf[i] < min) min = buf[i]; /* 记录最小值 */
}
/* 去掉一个最高分、去掉一个最低分,剩下 8 个取平均 */
return (uint16_t)((sum - max - min) / 8);
}
|
这个算法能有效滤掉偶发的尖峰干扰,是工程上非常实用的一招。
④ 硬件滤波
在 ADC 引脚和 GND 之间并一个 100nF 陶瓷电容。这是最便宜也最有效的措施,几乎所有正规产品的 ADC 输入都会加这个电容。
⑤ 检查 VREF 和布线
VREF+ 引脚要接干净的 3.3V,加 100nF + 1μF 去耦电容
- 模拟地(VSSA)和数字地要在一点汇合(单点接地)
- ADC 信号线远离时钟线、PWM 输出线、开关电源
Q4:想读芯片内部温度怎么办
内部温度传感器接在 ADC_CHANNEL_16 上,需要额外使能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
/* 使能内部温度传感器和 VREFINT 通道 */
adc_tempsensor_vrefint_enable();
/* 采集温度通道,采样时间必须给足(手册要求 ≥17.1μs) */
uint16_t temp_raw = adc_channel_sample_with_time(ADC_CHANNEL_16,
ADC_SAMPLETIME_239POINT5);
/* 换算成摄氏度
GD32F30x 手册给的公式:
T(°C) = (V25 - Vsense) / Avg_Slope + 25
其中 V25 = 1.43V(25°C 时的电压),Avg_Slope = 4.3mV/°C */
float v_sense = temp_raw * 3.3f / 4095.0f;
float temperature = (1.43f - v_sense) / 0.0043f + 25.0f;
printf("chip temp = %d.%d C\r\n", (int)temperature,
(int)(temperature * 10) % 10);
|
别指望它测环境温度。这个传感器测的是芯片内部结温,会比环境温度高 5~15°C(取决于芯片负载),而且不同芯片个体差异能到 ±10°C。它的正确用途是监测芯片是否过热,或者做温度变化的相对判断,不能当温度计用。
Q5:4 个通道能不能一次全采完,不要一个个来?
可以,这就是扫描模式 + DMA 的方案。核心改动:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
void adc_config_dma_version(void)
{
dma_parameter_struct dma_init_struct;
rcu_periph_clock_enable(RCU_DMA0);
/* --- ADC 部分:序列长度改成 4,开扫描模式和连续模式 --- */
adc_mode_config(ADC_MODE_FREE);
adc_data_alignment_config(ADC0, ADC_DATAALIGN_RIGHT);
adc_channel_length_config(ADC0, ADC_ROUTINE_CHANNEL, 4U); /* 4 个通道 */
/* 把 4 个通道依次排进序列的 0/1/2/3 位置 */
adc_routine_channel_config(ADC0, 0U, ADC_CHANNEL_1, ADC_SAMPLETIME_55POINT5);
adc_routine_channel_config(ADC0, 1U, ADC_CHANNEL_2, ADC_SAMPLETIME_55POINT5);
adc_routine_channel_config(ADC0, 2U, ADC_CHANNEL_3, ADC_SAMPLETIME_55POINT5);
adc_routine_channel_config(ADC0, 3U, ADC_CHANNEL_4, ADC_SAMPLETIME_55POINT5);
adc_special_function_config(ADC0, ADC_SCAN_MODE, ENABLE); /* 扫描模式 */
adc_special_function_config(ADC0, ADC_CONTINUOUS_MODE, ENABLE); /* 连续转换 */
/* --- DMA 部分:外设(ADC_RDATA) → 内存(adc_value 数组) --- */
dma_deinit(DMA0, DMA_CH0);
dma_init_struct.direction = DMA_PERIPHERAL_TO_MEMORY;
dma_init_struct.periph_addr = (uint32_t)(&ADC_RDATA(ADC0));
dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; /* 外设地址不变 */
dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_16BIT;
dma_init_struct.memory_addr = (uint32_t)adc_value;
dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; /* 内存地址自增 */
dma_init_struct.memory_width = DMA_MEMORY_WIDTH_16BIT;
dma_init_struct.number = 4U;
dma_init_struct.priority = DMA_PRIORITY_HIGH;
dma_init(DMA0, DMA_CH0, &dma_init_struct);
dma_circulation_enable(DMA0, DMA_CH0); /* 循环模式:搬完 4 个自动回到开头 */
dma_channel_enable(DMA0, DMA_CH0);
adc_dma_mode_enable(ADC0); /* ADC 每次 EOC 自动请求 DMA */
adc_enable(ADC0);
delay_1ms(1U);
adc_calibration_enable(ADC0);
adc_software_trigger_enable(ADC0, ADC_ROUTINE_CHANNEL); /* 只需触发一次! */
}
/* 主循环变成这样 —— 什么都不用做,随时读数组就有最新值 */
int main(void)
{
/* ... 初始化 ... */
adc_config_dma_version();
while(1){
printf("%d %d %d %d\r\n", adc_value[0], adc_value[1],
adc_value[2], adc_value[3]);
delay_1ms(500);
}
}
|
这就是实际项目的标准写法:配置一次,之后 ADC + DMA 在后台自动持续采集刷新数组,CPU 完全解放。详细讲解见本模块的 ADC0_routine_channel_with_DMA 示例。
Q6:怎么提高精度到 12 位以上?
用过采样。原理是采样 N 次求和后右移,利用噪声的随机性提高有效位数:
- 采 4 次求和右移 1 位 → 等效 13 位
- 采 16 次求和右移 2 位 → 等效 14 位
- 采 64 次求和右移 3 位 → 等效 15 位
- 采 256 次求和右移 4 位 → 等效 16 位
GD32F30x 部分型号有硬件过采样器,配置一次就自动完成,见 ADC1_oversample_shift 示例。没有硬件支持的型号可以软件实现:
1
2
3
4
5
6
7
8
9
10
|
/* 软件 16 倍过采样,等效 14 位精度 */
uint16_t adc_oversample_14bit(uint8_t channel)
{
uint32_t sum = 0;
uint8_t i;
for(i = 0; i < 16; i++){
sum += adc_channel_sample(channel);
}
return (uint16_t)(sum >> 2); /* 求和后右移 2 位,得到 0~16383 */
}
|
过采样有效的前提是噪声是随机白噪声。如果是 50Hz 工频串扰这种周期性干扰,过采样帮助有限,得靠屏蔽和硬件滤波。
小结
| 知识点 |
要记住的核心 |
| ADC 引脚模式 |
必须 GPIO_MODE_AIN,不是 IN_FLOATING |
| ADC 时钟 |
必须用 rcu_adc_clock_config() 分频,推荐 ≤14MHz |
| 三套时钟 |
GPIO 时钟 + ADC 外设时钟 + ADC 时钟分频,都要配 |
| 软件触发也要开 ETERC |
adc_external_trigger_config(..., ENABLE) 少不了 |
| 使能后必须延时再校准 |
adc_enable() → delay → adc_calibration_enable(),顺序不能错 |
| 常规组只有一个数据寄存器 |
多通道要么一个个手动读,要么用 DMA |
volatile |
只用于调试观察的变量必须加,否则会被优化掉 |
| 电压换算 |
mV = code * 3300 / 4095,先乘后除全整数运算 |
| 读数跳动 |
加大采样时间 → 降低 ADC 时钟 → 软件滤波 → 加 100nF 电容 |
ADC 采样七步曲:
1
2
3
4
5
6
7
|
1. rcu_periph_clock_enable(RCU_GPIOA); /* GPIO 时钟 */
2. rcu_periph_clock_enable(RCU_ADC0); /* ADC 时钟 */
3. rcu_adc_clock_config(RCU_CKADC_CKAPB2_DIV6); /* ADC 分频 */
4. gpio_init(..., GPIO_MODE_AIN, ...); /* 引脚模拟输入 */
5. adc_mode_config / data_alignment / channel_length / trigger /* 参数 */
6. adc_enable() → delay_1ms(1) → adc_calibration_enable() /* 使能校准 */
7. channel_config → software_trigger → wait EOC → data_read /* 采样 */
|
拓展应用
学会 ADC,你就能读取现实世界的连续量了。可做的项目一下子多起来:
入门级(一个电位器就能玩)
- 数字电压表:把 ADC 读数换算成电压,通过串口或 OLED 显示。加个电阻分压网络(比如 10:1)就能测 0~33V,做一个万用表的电压档。
- 旋钮调光灯:电位器读数映射到 PWM 占空比,转旋钮就能无级调节 LED 亮度。这是 ADC + TIMER 的第一个综合应用。
- 光照检测夜灯:光敏电阻(LDR)和固定电阻串联分压,ADC 读电压判断明暗,天黑自动开灯。
- 电池电量指示:分压后测锂电池电压,映射成 4 格电量图标。注意锂电池 4.2V 超过 3.3V,必须先分压。
- 摇杆/游戏手柄读取:双轴摇杆本质就是两个电位器,读 X/Y 两路 ADC 就能得到方向和力度。
进阶级(配合传感器和算法)
- NTC 热敏电阻测温:这是最常见的低成本测温方案。ADC 读分压电压 → 算出电阻值 → 查 B 值公式或分段查表得到温度。精度可到 ±0.5°C,比内部温度传感器强得多。
1
2
3
4
|
/* NTC 分压电路:3.3V — R固定(10k) — NTC — GND,ADC 接 NTC 上端 */
float r_ntc = 10000.0f * adc_val / (4095.0f - adc_val);
/* B 值公式:1/T = 1/T0 + (1/B) * ln(R/R0) */
/* T0=298.15K(25°C), R0=10000Ω, B=3950 */
|
- 土壤湿度 / 水位检测:电容式或电阻式传感器输出模拟量,ADC 直接读,做自动浇花系统。
- 压力/称重(配合 HX711 或运放):应变片桥式输出几毫伏,经运放放大后 ADC 采集,做电子秤。这时 12 位精度可能不够,要用过采样或外置 24 位 ADC。
- 多路电压巡检仪:16 路 ADC + DMA 自动扫描,做配电柜多回路电压监测。
- 红外测距(Sharp GP2Y0A21):这类传感器输出的电压和距离成反比曲线,查表插值后能得到 10~80cm 的距离值,做避障小车。
- 气体传感器(MQ 系列):MQ-2 烟雾、MQ-7 一氧化碳等都是模拟输出,ADC 读浓度做报警器。
信号处理级
- 示波器:ADC + DMA + 定时器触发实现固定采样率采集,数据传到电脑上位机画波形。GD32F307 的 2.6MSPS 采样率能做到 1MHz 带宽,足够看 I2C/UART 波形。
- 音频采集与 FFT 频谱:麦克风模块输出经运放偏置到 1.65V 中心,ADC 以 8kHz 采样,做 256 点 FFT 得到频谱,实现声控开关、音乐律动灯、简易调音器。
- 交流电流检测(互感器):电流互感器输出正弦波,快速采样一个周期后算 RMS 有效值,做智能插座功率计量。
1
2
3
4
5
6
7
8
|
/* 采一个周期(50Hz=20ms)的数据算 RMS */
uint32_t sum_sq = 0;
for(i = 0; i < 200; i++){ /* 200 点,每点 100μs */
int16_t ac = adc_channel_sample(ch) - 2048; /* 减去 1.65V 直流偏置 */
sum_sq += ac * ac;
delay_us(100);
}
/* rms = sqrt(sum_sq / 200) */
|
- PID 闭环控制:ADC 读反馈量(温度、转速、位置)→ PID 计算 → PWM 输出执行。这是自动控制的核心闭环,做恒温箱、电机调速、平衡车都靠这个。
工业与安全级
- 过压/过流保护(模拟看门狗):不用 CPU 参与,ADC 硬件自动比较上下限,超限立刻触发中断关断输出。响应时间可以做到微秒级,比软件判断快几个数量级。见
ADC1_analog_watchdog 示例。
- 电机 FOC 控制:双 ADC 并行模式在 PWM 中心点同时采样三相电流,配合 Clarke/Park 变换实现磁场定向控制。这是无刷电机高性能驱动的标准方案。
- 4-20mA 工业信号采集:工业传感器普遍用 4-20mA 电流环(抗干扰强、可远传)。经过一个精密电阻(如 165Ω)转成 0.66~3.3V 电压后 ADC 采集。
下一步该学什么:ADC0_routine_channel_with_DMA(多通道自动采集,实际项目标准做法)→ ADC0_temperature_Vref(读内部温度和参考电压)→ TIMER1_pwmout(学会 PWM 输出,就能做 ADC 读旋钮 → PWM 调光的完整闭环了)。