简介:基于8051F020微控制器、以C语言实现DAC0输出DTMF双音多频信号的嵌入式源码工程,很适合学习C语言与单片机编程的开发者,用于掌握硬件寄存器操作、定时器中断及数字模拟转换的完整流程。压缩包共10个文件,涵盖.c源文件、.h头文件、Keil工程文件(.uv2/.opt)、编译生成的.obj/.m51/.lst以及备份文件等,整体仅19KB,结构紧凑。已有85人学习下载,可作为嵌入式入门的小型参考项目。通过分析源码,可了解如何用Keil uVision或IAR等工具配置8051F020,设置DAC0控制寄存器与工作模式;利用定时器T4产生周期中断,依据DTMF码表更新DAC0输出,实现高低频组合的电话信令音频。工程中还能看到中断服务函数、延时处理与循环码表的设计思路,对理解嵌入式C语言的实际工程组织和调试方式有直接帮助。
1. DAC0_DTMF1:一个用C语言在单片机上“拨号”的小程序,到底难在哪
DAC0_DTMF1 这个名字,一看就是嵌入式源码工程里的产物:DAC0 指的是 DAC 的 0 号输出通道,DTMF 是电话机拨号用的双音多频信号,后面的 C 语言源码设置、C 语言程序则说明任务很明确——用 C 语言写出一个能从 DAC0 引脚输出 DTMF 拨号音的代码。DTMF 原理一点都不复杂,两个正弦波叠加起来就是一位按键;但真到源码里,坑全在采样率、查表长度、相位累加和幅度配比这些参数上。频率表背对了结果可能还是拨不了号,因为定时器触发频率和 DAC 更新率没有对齐。这篇文章按“频率表算明白 → C 语言实现 → 完整程序骨架 → 信号验证”的顺序,把每一步参数和代码直接摆出来,适合做自动拨号、电话测试源或 DTMF 解码器自检信号的工程师。
2. DTMF信号生成前,先把频率表和采样率算明白
2.1 双音多频的8个频率和4x4键位表
DTMF 标准把电话键盘做成一个 4×4 的矩阵,行和列各给一个频率,按下一个键,就把对应行频和列频叠加成双音信号。频率是严格按标准选的,不能随意换,解码端会按矩阵反查按键。下面这张表是 16 个键的完整组合,写 C 语言源码时可以直接按这个顺序存成一个常量数组。
| 键位 | 低频(Hz) | 高频(Hz) | 键位 | 低频(Hz) | 高频(Hz) |
|---|---|---|---|---|---|
| 1 | 697 | 1209 | 7 | 852 | 1209 |
| 2 | 697 | 1336 | 8 | 852 | 1336 |
| 3 | 697 | 1477 | 9 | 852 | 1477 |
| A | 697 | 1633 | C | 852 | 1633 |
| 4 | 770 | 1209 | * | 941 | 1209 |
| 5 | 770 | 1336 | 0 | 941 | 1336 |
| 6 | 770 | 1477 | # | 941 | 1477 |
| B | 770 | 1633 | D | 941 | 1633 |
这个表里最容易漏掉的是 A/B/C/D 四个键和 *、# 两个键。很多 DMTF 生成程序只做 0-9,真到了交换机兼容测试、电话计费模拟器或对讲机信令测试时,缺了这几个键就是缺功能。低频组和高频组各自最大值分别是 941Hz 和 1633Hz,所以后续采样率不能只按 697Hz 来算。
拿到键位表后,源码里最省事的数据结构是uint16_t[16][2],行下标直接对应键位顺序“123A456B789C*0#D”。这样做的好处是后面按键转频率时不用写一堆 if 分支,一次查表就完成。
2.2 采样率、相位累加和查表法:让 697Hz 也能用 256 点表发准
DAC0_DTMF1 这类程序里,正弦波的生成几乎不会在中断里实时调用sin(),因为 16kHz 采样率下每秒要更新 16000 个点,C 库函数算双音加法的耗时很容易吃掉大部分 CPU。常见做法是预先算一张正弦表,运行时用相位累加器去查表。
采样率的选择直接影响音质和资源占用。DTMF 最高频是 1633Hz,按奈奎斯特定理,采样率 3266Hz 以上就不会丢信息,但实际要留出滤波过渡带,一般取 8kHz、16kHz 或 32kHz。8kHz 在电话语音链路里够用,但有商店老式交换机对高频毛刺敏感;32kHz 又让定时器中断频率翻倍。我一般选 16kHz,这是音质和 CPU 负载之间比较稳的折中值。
固定采样率带来的问题是:697Hz 信号在 16kHz 下每个周期只有 22.95 个采样点,不是整数。直接用 23 点循环做 697Hz,实际频率就会偏到约 695.65Hz,累积误差会更高。解决办法是相位累加器:
#include <stdint.h> #define SAMPLE_RATE 16000u #define SINE_TABLE_LEN 256u uint32_t step_l, step_h; uint32_t phase_l, phase_h; uint32_t make_step(uint32_t freq) { return (uint32_t)(((uint64_t)freq << 32) / SAMPLE_RATE); } void set_dtmf_freq(uint32_t f_low, uint32_t f_high) { step_l = make_step(f_low); step_h = make_step(f_high); }make_step()的含义是:一个完整正弦波周期对应相位累加器跑满2^32,频率越高,每个采样周期里相位前进的步长越大。因为freq * 2^32 / sample_rate可能超过 32 位,所以先用uint64_t做中间运算再截断回uint32_t。
运行时只需要两条整数加法:
phase_l += step_l; phase_h += step_h;uint32_t累加到溢出时自动回到 0,正好对应正弦波一个周期的结束。高 8 位作为 256 点表的索引,其余低 24 位保留小数精度。以 697Hz 为例,理论频率误差不到 0.01Hz,远低于电话交换机 ±2% 的容限。
2.3 双音信号叠加后幅度怎么控制在DAC满量程内
DAC 输出寄存器只能写无符号的码值,比如 12 位 DAC 的范围是 0~4095,中间电平是 2048。两个正弦波如果各自摆幅都达到满量程,叠加后的峰值会超过上限,直接削波;如果直接相加后再偏置,双音幅度强的那一位会把弱的一位盖住,解码端就可能识别成另一个按键。
常见做法是先把正弦表存成以 128 为中心的 0~255 无符号值,查表后用减 128 得到有符号波形,再按照下面的方式混合:
int32_t s_l = (int32_t)sine_table[(uint8_t)(phase_l >> 24)] - 128; int32_t s_h = (int32_t)sine_table[(uint8_t)(phase_h >> 24)] - 128; int32_t mixed = ((s_l + s_h) >> 1) + 128; /* 双音混频,先压一半幅度 */ DAC_DHR12R1 = (uint32_t)mixed * 16u; /* 8bit偏置码扩到12bit */这里最关键的是>> 1这一下。s_l和s_h范围都是 -128~127,相加后范围是 -256~254,右移一位后回到 -128~127,再加 128,又回到 0~255。这样就保证两个频率在叠加后都不会突破 DAC 编码范围,同时两路信号幅度比例保持 1:1,符合 DTMF 解码器对双音电平差的要求。
DAC_DHR12R1是 12 位右对齐数据寄存器,把 0~255 的混合结果乘以 16,正好映射到 0~4080,留出大约 15 个 LSB 的余量。如果 MCU 的 DAC 是 8 位,那最后一步的* 16u直接去掉就行。
提示:如果不想用“减 128 再加 128”的整数处理,正弦表也可以直接存成 -128~127 的有符号数组,但很多 DAC 外设只接受无符号码值,最后仍要做一次偏置,算力差别不大。
3. 用C语言实现DAC0输出DTMF:从初始化到查表都写成代码
3.1 DAC0初始化的寄存器级C语言源码设置
不同 MCU 对 DAC 通道的命名不一样,STM32 里可能写作 DAC_OUT1,Arduino 平台叫 DAC0。下面的代码按 Cortex-M 常见 DAC 寄存器的风格写,寄存器地址是 STM32 的常见排布,换到别的芯片时把DAC_BASE_ADDR和 GPIO 基地址换成参考手册里的值即可。
#define DAC_BASE_ADDR 0x40007400u #define DAC_CR (*(volatile uint32_t *)(DAC_BASE_ADDR + 0x00u)) #define DAC_DHR12R1 (*(volatile uint32_t *)(DAC_BASE_ADDR + 0x08u)) #define GPIOA_MODER (*(volatile uint32_t *)(0x40020000u)) #define RCC_AHB1ENR (*(volatile uint32_t *)(0x40023830u)) void dac0_init(void) { RCC_AHB1ENR |= (1u << 0); /* 打开GPIOA时钟 */ GPIOA_MODER &= ~(0x3u << 8); /* 先清PA4模式位 */ GPIOA_MODER |= (0x3u << 8); /* PA4设为模拟模式 */ DAC_CR &= ~(0x3Fu << 0); /* 清DAC0通道控制位 */ DAC_CR |= (0x3u << 0); /* EN1=1使能,BOFF1=1关缓冲 */ DAC_DHR12R1 = 2048u; /* 先输出中点电平 */ }两个特别值得注意的点。第一个是 GPIO 模式,DAC 输出引脚通常要配置成模拟模式,而不是复用推挽输出;误配成 GPIO 复用后,DAC 写入值会被内部输出缓冲干扰,波形始终不对。第二个是BOFF1=1表示关闭输出缓冲,适合直接接运放或高阻负载;如果要驱动 2kΩ 以下的重负载,把 0x3u 改成 0x1u 打开缓冲,但输出直流误差会稍微变大,DTMF 只需要几十毫伏精度,影响不大。
初始化最后写2048u,是为了避免系统上电瞬间 DAC 输出从 0 跳到中间电平。虽然只是启动时的一次阶跃,但通过音频链路会变成一声爆音。先把 DAC 停在满量程中点,后面切换静音和拨号音时也是从中点出发,噪声会少很多。
3.2 C语言程序生成正弦查表,不要手工敲
正弦表可以手工预编译成常量数组,也可以在启动时用 C 程序算一次。表里有 256 个点,手工敲容易敲错一个索引,波形上出现一个毛刺,电话解码端不一定察觉,但 FFT 下会多出杂散分量。我一般直接在源码里保留生成函数:
#include <math.h> uint8_t sine_table[256]; void sine_table_init(void) { for (uint16_t i = 0; i < 256; i++) { double v = sin(2.0 * 3.14159265358979 * (double)i / 256.0); sine_table[i] = (uint8_t)(128.0 + 127.0 * v); } }这段代码把正弦波的一个完整周期切成 256 个点,每个点的值范围是 0~255。中心是 128,峰值为 127,完全避开了负数运算,后续混音时只需要一次减法和一次移位。这个初始化只跑一次,放在main()开头即可。
如果对中断实时性要求很高,不想让启动时间被 256 次sin()拖慢,那就在电脑上用同样的公式把表打印出来,生成const uint8_t sine_table[256]数组,然后删掉计算函数。两种方式产出的波形完全一致,后面的查表代码不用改。
3.3 定时器中断里更新DAC,输出采样率16kHz
DAC 的输出不是一次性写完就不管了,而是按采样率持续更新。要让 DAC0_DTMF1 发出连续的 DTMF 音,就必须有一个稳定周期调用查表函数,最直接的是定时器中断。
中断服务函数保持短小:
static void timer_isr(void) { if ((TIM->SR & TIM_SR_UIF) != 0) { TIM->SR = ~TIM_SR_UIF; /* 清更新标志 */ dac_tone_update(); /* 每次采样周期更新一次DAC */ } }dac_tone_update()在后面实现,它只做相位累加、查表和混合,不需要浮点运算,也没有取模,所以整个中断执行时间在 72MHz 主频的 Cortex-M 上大约只有一两微秒,16kHz 中断完全跑得动。
定时器的更新频率就是采样率。更新频率计算公式为:
f_update = f_timer_clock / ((PSC + 1) * (ARR + 1))比如定时器时钟 72MHz,要得到 16000Hz,可以取PSC=44、ARR=99,因为 72MHz / (45 × 100) = 16000Hz。如果 MCU 用了 PLL 分频,定时器时钟不是外部晶振频率,这个公式里的f_timer_clock要按实际时钟树配置来填。定时器频率偏了,所有频率都会跟着偏,所以先在这里花一分钟算准,后面省很多调试时间。
3.4 按键频率映射表和混音更新函数:DAC0_DTMF1.c 的核心
把键位表、频率表和相位累加放到一起,就得到DAC0_DTMF1.c里最核心的部分。先用一个数组把 16 个键的行列位置定死:
typedef struct { uint32_t step_low; uint32_t step_high; } dtmf_step_t; static const uint8_t dtmf_row[16] = { 0,0,0,0, /* 1,2,3,A */ 1,1,1,1, /* 4,5,6,B */ 2,2,2,2, /* 7,8,9,C */ 3,3,3,3 /* *,0,#,D */ }; static const uint8_t dtmf_col[16] = { 0,1,2,3, 0,1,2,3, 0,1,2,3, 0,1,2,3 }; static const uint16_t dtmf_freq_low[4] = {697, 770, 852, 941}; static const uint16_t dtmf_freq_high[4] = {1209, 1336, 1477, 1633}; static uint32_t step_l, step_h; static uint32_t phase_l, phase_h;然后写按键设置函数:
void dac0_set_tone(uint8_t tone_id) { if (tone_id == 0xFF) /* 0xFF表示静音 */ { step_l = 0; step_h = 0; phase_l = 0; phase_h = 0; DAC_DHR12R1 = 2048u; /* 立即落到中点 */ return; } uint32_t f_low = dtmf_freq_low[dtmf_row[tone_id]]; uint32_t f_high = dtmf_freq_high[dtmf_col[tone_id]]; step_l = (uint32_t)(((uint64_t)f_low << 32) / SAMPLE_RATE); step_h = (uint32_t)(((uint64_t)f_high << 32) / SAMPLE_RATE); phase_l = 0; phase_h = 0; }中断里的更新函数负责每个采样点刷新 DAC:
void dac_tone_update(void) { phase_l += step_l; phase_h += step_h; int32_t s_l = (int32_t)sine_table[(uint8_t)(phase_l >> 24)] - 128; int32_t s_h = (int32_t)sine_table[(uint8_t)(phase_h >> 24)] - 128; int32_t mixed = ((s_l + s_h) >> 1) + 128; DAC_DHR12R1 = (uint32_t)mixed * 16u; }tone_id按 0~15 对应键盘顺序“123A456B789C*0#D”,0xFF 是空键哨兵值。把静音检查放在dac0_set_tone()而不是中断里,是为了保证中断函数里没有分支抖动,执行时间固定。phase清零则是为了让每次按键都从相位 0 开始,避免上一个按键残留的相位导致连续切换时出现幅度突变。
注意:如果 MCU 的 DAC 数据寄存器是左对齐而不是右对齐,最后一行
* 16u本质是把 8bit 有效码值移到 12bit 高位,等价于mixed << 4,这点在移植到其他平台时要确认寄存器是DHR12R1还是DHR12L1。
4. 把DAC0_DTMF1的C语言程序骨架串成一体:main函数、拨号序列和静音控制
4.1 main函数:先初始化所有外设,再自动拨号
前面的代码是零件,现在组装成完整程序。main()里要做的事很少:初始化外设、把拨号序列转换成 tone_id、安排好按键持续时间和间隔。
#include <string.h> uint8_t char_to_dtmf_id(char c) { const char *keys = "123A456B789C*0#D"; const char *p = strchr(keys, c); return (p && *p) ? (uint8_t)(p - keys) : 0xFF; } int main(void) { dac0_init(); sine_table_init(); timer_init(16000); const char *digits = "13800138000#"; /* 要拨的号码和结束符 */ for (int i = 0; digits[i] != '\0'; i++) { uint8_t id = char_to_dtmf_id(digits[i]); if (id == 0xFF) continue; /* 跳过无法识别的字符 */ dac0_set_tone(id); delay_ms(100); /* 每个键发100ms */ dac0_set_tone(0xFF); /* 静音间隔 */ delay_ms(80); } while (1) { /* 实际项目里放按键扫描或状态机 */ } }这段代码里char_to_dtmf_id()用strchr()定位字符位置,比在 main 里写 switch 简洁,也不容易漏掉*和#。strchr()对'\0'会返回指向字符串末尾终止符的指针,所以加了&& *p判断,避免把空字符映射成第 16 个键。
阻塞式delay_ms()只在验证阶段用。真实产品里,如果主循环还要处理 LCD、按键或网络协议,阻塞会让整个拨号流程卡住。常见做法是把拨号序列放到一个数组里,由定时器状态机每 100ms 推进一位,main 循环只负责查询当前状态。
4.2 参数设置表:这些数字决定能不能拨通
下面这张表是实际调 DAC0_DTMF1 时最常碰到的参数。每个参数都会直接影响解码成功率,建议先按推荐值跑通,再根据测试结果微调。
| 参数 | 推荐值 | 作用与调整方向 |
|---|---|---|
| SAMPLE_RATE | 16000Hz | 低于8000Hz时会增加混叠风险;高于32000Hz会加大中断负载 |
| SINE_TABLE_LEN | 256 | 点数越少谐波越明显;128点也能用,256点更干净 |
| 音频时长 | 100ms | 最短约40ms;太短交换机会漏检,太长会拖慢连续拨号 |
| 键间隔 | 80ms | 常用50~100ms;隔太短会把两个键连成一条长音 |
| DAC分辨率 | 12bit | 8bit能驱动解码器,但量化噪声稍大,输出比例因子需要调整 |
| 静音电平 | 2048 | 必须为DAC中点;输出0会把直流阶跃传到下一级电路 |
如果拨号后对端没有任何反应,先查采样率有没有真实跑到 16kHz,再查音频时长是否过低。频率表错误反而出现在后期,排查优先级反而不高。
4.3 定时器频率不对怎么排查:用GPIO翻转法量实际采样率
很多情况下板子实际定时器时钟和预期不一致,比如 PLL 配置错误、总线分频忘记设置,导致PSC和ARR算出来是 16kHz,实际上只有 15.2kHz。此时 DTMF 所有频率等比例偏低,解码器基本无法识别。
最快的验证方法是在定时器中断里翻转一个空闲 GPIO:
static void timer_isr(void) { if ((TIM->SR & TIM_SR_UIF) != 0) { TIM->SR = ~TIM_SR_UIF; /* 清更新标志 */ DBG_GPIO->ODR ^= (1u << 1); /* 翻转调试脚PB1 */ dac_tone_update(); /* 更新DAC */ } }用示波器量PB1的方波周期,如果刚好是 62.5μs,对应的就是 16kHz 采样率。这个方法比任何仿真器变量都直观,因为它量到的是中断真正执行响应的节奏,而不是代码里配置的数值。如果周期偏了,用公式反推PSC和ARR,而不是直接改中断函数里的延时补偿。
5. 让DAC0输出的DTMF更干净:验证、抗混叠和三个必调参数
5.1 用FFT看双音谱线,先确认频率没偏
DAC 输出的波形用示波器看,只能看到一条不太平滑的正弦包络,很难判断双音是否精确。最可靠的办法是 FFT。把 DAC0 输出接到音频采集端,用 16kHz 采样率采 2048 个点,加 Hann 窗后看频谱。正常按键 1 应该在 697Hz 和 1209Hz 各有一根谱线,两边没有明显杂散峰。如果谱线位置偏移超过 ±2%,先检查采样率;如果主峰旁边出现间隔约 16kHz 的边带,那是 DAC 更新频率的镜像,需要后面的 RC 低通处理。
5.2 三个必调参数,按顺序来
调谐顺序建议固定。第一调静音电平:确保dac0_set_tone(0xFF)后 DAC 数据寄存器是 2048,不是 0;输出 0 会让电感负载发出“咔哒”一声,这个噪声带宽很宽,容易被 DTMF 解码器误判为一个无效脉冲。第二调输出比例:12bit DAC 用mixed * 16u,8bit DAC 直接用mixed;如果寄存器是左对齐格式,* 16u和<< 4等价,但不能多乘。第三调相位重置:dac0_set_tone()里必须清零phase_l和phase_h,否则切键时两个波的相位会从任意位置继续,造成输出波形瞬间跳变,虽然解码器大多能容忍,但下一级模拟电路会多出一串瞬态分量。
5.3 输出端加一阶RC低通,滤掉16kHz镜像
DAC 输出本质是零阶保持信号,频谱里有采样频率及其整数倍的镜像。给 DAC0 后面加一个一阶 RC 低通,截止频率取 6~8kHz 比较合适,因为最高 DTMF 频率是 1633Hz,6k 以上的衰减不会影响有效信号。选型公式是fc = 1 / (2πRC)。例如 R=1kΩ、C=22nF,截止频率约 7.23kHz;换成 10nF 会到 15.9kHz,滤镜像效果偏弱,而 33nF 会到 4.8kHz,1633Hz 会被轻微衰减,所以 22nF 是最常用的组合。接法是在 DAC0 引脚串联 1kΩ 电阻,再对地接 22nF 电容,电容两端作为输出。调完 RC 后把 FFT 采样点数提升到 8192,镜像边带应该被压到 -40dB 以下;如果还有与按键频率无关的杂散峰,回头检查定时器中断里是否被其他高优先级中断抢断,导致每次 DAC 更新的间隔抖动。
本文还有配套的精品资源,点击获取