简介:面向8051F020微控制器平台上使用C语言实现DAC0输出DTMF双音多频信号的需求,这份源码包提供了完整的Keil工程和程序示例,尤其适合希望深入学习寄存器配置、定时器中断以及数模转换原理的嵌入式初学者。压缩包共包含10个文件,整体仅19KB,主要文件包括主程序c源码、c8051F020硬件头文件、Keil工程配置以及编译生成的obj、lst、m51等过程文件,可帮助使用者省去搭建工程的时间,直接查看编译结果并对照修改。程序的核心思路是利用定时器T4产生周期性中断,在中断服务函数中根据预设的DTMF码表更新DAC0寄存器输出,从而实现不同频率组合的音频信号;源码中还展示了硬件寄存器的定义、延时控制方法以及码表切换逻辑,能够较为完整地呈现嵌入式C语言从底层寄存器操作到上层中断控制流的协作过程。压缩包内的备份文件可用于恢复工程设置,方便不同环境下的二次修改与调试。已有85人学习,对于想通过紧凑实例动手掌握单片机C语言编程、理解DTMF生成机制的用户,是一份值得参考的实用源码资料。
1. 从电话拨号音到嵌入式C语言源码
按下电话拨号键时听到的“嘟”声,就是DTMF双音多频信号。DAC0_DTMF1这个Keil工程要做的,是在Silicon Labs C8051F020增强型8051微控制器上,用内部DAC0把数字序列实时还原成模拟电压波形,从而输出电话网能识别的DTMF拨号音。和常见的用定时器翻转IO脚产生方波不同,DAC方案输出的是真正逼近正弦波的模拟信号,配合RC滤波可以直接送入音频通道而不产生大量谐波干扰,这对电话拨号这类有带宽要求的场景很关键。
这个工程适合两类人:一类是想把C语言从“只会写PC小程序”推进到“能操作寄存器、写中断服务函数”的嵌入式初学者,另一类是在做电话拨号、对讲机信令、远程控制等需要产生DTMF音频信号的开发人员。工程里的核心链路是:C8051F020内置的DAC0作为模拟输出通道,T4定时器产生采样中断,中断服务函数中根据DTMF码表更新DAC寄存器,最终在引脚上合成出两个频率叠加的波形。读懂这份源码,也就顺势掌握了C语言在硬件寄存器配置和时序控制上最典型的用法。
2. DTMF双音多频原理与DAC0寄存器选型
2.1 DTMF频率配对表与行列编码
DTMF用两个正弦波叠加表示一个按键,一个来自低频组(行频),一个来自高频组(列频)。标准频率配对如下:
| 按键 | 行频Hz | 列频Hz |
|---|---|---|
| 1 | 697 | 1209 |
| 2 | 697 | 1336 |
| 3 | 697 | 1477 |
| 4 | 770 | 1209 |
| 5 | 770 | 1336 |
| 6 | 770 | 1477 |
| 7 | 852 | 1209 |
| 8 | 852 | 1336 |
| 9 | 852 | 1477 |
| 0 | 941 | 1336 |
| * | 941 | 1209 |
| # | 941 | 1477 |
注意DTMF对时序有比较严格的要求:两个频率必须同时存在且同时结束,持续时间和间隔时间都有标准。常用的拨号格式是“先导通后断开”,即保持某个按键对应频率40ms以上,间隔20ms以上再送下一个码。工程里如果只是循环发送而不是按键即时触发,应当在码与码之间插入显式的静音时间,否则接收端可能把连续的两个相同按键误判成一个长按键。
2.2 DAC0控制寄存器DAC0CN与输出模式
C8051F020的DAC0是12位电压输出型,输出范围可配置为0到VREF或0到VREF×2。它不像外挂DAC芯片那样需要SPI或I2C时序,只需配置控制寄存器DAC0CN后直接写数据寄存器DAC0。DAC0CN的关键位如下:
| 位 | 名称 | 作用 |
|---|---|---|
| 7 | DAC0EN | 置1使能DAC0 |
| 6 | DAC0MD1 | 与DAC0MD0组合决定更新方式 |
| 5 | DAC0MD0 | 输出更新时机选择 |
| 4 | DAC0DF2 | 数据格式,置1为12位左对齐 |
| 3 | DAC0DF1 | 保留或配合格式选择 |
| 2 | DAC0DF0 | 保留 |
| 1 | DAC0CN1 | 保留 |
| 0 | DAC0CN0 | 保留 |
一个常用的初始化写法是:
void DAC0_Init(void) { DAC0CN = 0x90; // 使能DAC0, 左对齐12位模式, 写数据时立即更新 DAC0 = 0x0000; // 先输出0, 避免上电瞬间的随机电平 }注释里已经说明了0x90各位的意图。左对齐模式下,12位有效数据放在高12位,低4位写0即可。选择左对齐是因为很多参考电压计算和值表都以满量程4096为单位,直接把查表结果左移4位给DAC0就行,省去额外换算。
2.3 用查表还是用实时计算生成正弦波
生成正弦波有两种典型做法:一是调用数学库的sin函数实时计算,二是在程序里预存一张正弦波采样值表。C8051F020是增强型8051核心,没有硬件浮点单元,sin函数调用会消耗大量CPU时间,而且每次调用结果都在变化,难以保证中断周期内的确定性。工程代码里常见的做法是预先存一张256点的正弦表,每点覆盖2π/256的角度增量,输出时通过相位累加器查表,得到DAC0的写入值。
查表法的好处很明显:中断里只有加法和查表操作,CPU负载稳定在很低的水平,频率调节靠改变步长而不是改计算逻辑。步长和频率的关系用小学算术就能算清楚:采样率Fs,表长N,目标频率f,则每周期需要的累加步长step = f × N / Fs。工程里通常把步长用定点数表示,比如高16位是整数部分、低16位是小数部分,这样即使目标频率不是采样率的整数倍,也能保持长时间不积累误差。这个原理在后面的T4中断实现里会直接用到。
3. T4定时器中断触发DAC0输出采样点
3.1 T4作为精密采样时钟的配置方法
C8051F020的定时器T4是一个16位自动重载定时器,典型用法是作为周期中断源驱动DAC输出。和DAC配合的关键是采样率要足够高,保证能够还原目标频率。电话DTMF的最高频率是1633Hz,按照采样定理至少需要3266Hz,但为了波形平滑和滤波容易,工程里采样率通常取8kHz甚至更高。T4的重载值与系统时钟、分频数和采样率的关系是:
#define SYSCLK 24500000L // 假设系统时钟24.5MHz #define SAMPLE_RATE 8000L // 采样率8kHz void T4_Init(void) { unsigned int reload; reload = 65536 - (SYSCLK / 12 / SAMPLE_RATE); T4CON = 0x04; // 系统时钟12分频,16位自动重载模式 TMR4RL = reload; // 重载值写入寄存器对 TMR4 = reload; // 当前值同时写满 T4CON |= 0x40; // 启动T4 ET4 = 1; // 开T4中断 EA = 1; // 开总中断 }计算逻辑是这样的:系统时钟24.5MHz进入定时器前先经过12分频,得到约2.04MHz的计数频率。要让T4以8000Hz频率触发中断,计数值就是2041666 / 8000 ≈ 255,这个数小于65536,所以用16位定时器完全够用。reload = 65536 - 255。注意TMR4RL和TMR4两个寄存器都必须写入相同的值,前者用于自动重载,后者是当前计数值,缺一个会导致首次溢出时间不准。分频参数取自T4CON的低3位,工程里常见的0x04代表12分频,如果要切换采样率,优先调分频而不是硬算大重载值,因为分频能降低定时器本身的计数误差比例。
3.2 中断服务函数与DTMF码表的设计
中断服务函数是DTMF生成的核心。每个采样周期进入一次中断,在这个时间窗口内要完成两件事:查表得到当前时刻两个频率对应的正弦值,按比例叠加后写入DAC0。完整的实现结构如下:
unsigned char code sin_table[256] = { 128,131,134,137, /* ... 256个采样点, 覆盖0~2π */ }; static unsigned int phase_row, phase_col; void timer4_isr(void) interrupt 5 { static unsigned char row_freq_idx, col_freq_idx; unsigned char row_val, col_val; unsigned int dac_out; // 行频相位累加: 步长 = f_row * 256 / SAMPLE_RATE phase_row += row_step; row_val = sin_table[(unsigned char)(phase_row >> 8)]; // 列频相位累加 phase_col += col_step; col_val = sin_table[(unsigned char)(phase_col >> 8)]; // 两路信号叠加, 各占一半幅度 dac_out = ((unsigned int)(row_val - 128) + (col_val - 128) + 256) / 2; dac_out = dac_out * 4095 / 256; DAC0 = (unsigned int)dac_out << 4; }中断处理的过程拆开看:row_step和col_step是全局变量,由当前要播的按键决定,在main函数里调用dtmf_set_freq函数计算并更新。phase_row和phase_col是相位累加器,每次中断都加上对应步长,超过16位后自然溢出回卷到起点。取高8位作为正弦表索引,相当于把相位量化到256个离散点上。两路信号先各自减去128得到双极性值,叠加后再加256把结果平移到0到512的范围,除以2并换算到12位量程后写入DAC。注意DAC0是左对齐模式,所以低4位补零,右移还是左移要看寄存器配置选的哪种格式。
3.3 步长计算的定点数技巧
步长计算如果不做定点处理,每次中断都做一次浮点乘法,会严重影响中断实时性。工程中通常预先算好步长并存成全局变量:
static unsigned int row_step, col_step; void dtmf_set_freq(unsigned int f_row, unsigned int f_col) { // (f * 65536 * 256) / SAMPLE_RATE // 高16位小数部分与低16位整数部分直接拼成32位定点数 row_step = (unsigned int)((unsigned long)f_row * 65536L / SAMPLE_RATE); col_step = (unsigned int)((unsigned long)f_col * 65536L / SAMPLE_RATE); }注释里的换算逻辑是:频率f对应的每周期点数是SAMPLE_RATE / f,而相位累加器每一点的增量等于65536 ÷ 每周期点数,也就是f × 65536 / SAMPLE_RATE。把结果截断为16位是可行的,因为对697Hz这样的低频,步长远小于65536,截断误差在可接受范围内。如果想要更高精度,就把累加器扩展到32位,高16位查表,低16位保留精度。工程文件里的代码常常混用这两种方式,32位精度更高,代价是中断里多做一次16位加法和一次带进位加法,对于8kHz的中断频率,这个开销可以忽略。
4. 电话呼叫流程与多频切换的完整实现
4.1 主循环中按键或串口触发拨打
底层DAC和T4初始化完成后,需要上层逻辑把“按键”翻译成“频率切换”,再按时间关系把DTMF码送出去。主循环结构可以写成:
void main(void) { unsigned char key; SystemInit(); // 系统时钟, 端口配置 DAC0_Init(); // 初始化DAC0 T4_Init(); // 初始化T4定时器, 开中断 UART0_Init(); // 串口用于调试和输入 while (1) { if (UART0_IsCharAvailable()) { key = UART0_GetChar(); if (key >= '0' && key <= '9') { dtmf_play(key, 80); // 播放80ms DelayMs(40); // 间隔40ms } else if (key == '*' || key == '#') { dtmf_play(key, 80); DelayMs(40); } } } }主循环里不直接操作寄存器,而是调用dtmf_play这个调度函数。UART输入的设计方便调试:PC串口助手发一个字符,单片机就播一个DTMF码,逻辑清晰而且不用接矩阵键盘。dtmf_play内部要做的事情是:根据字符查表获得行频和列频,调用dtmf_set_freq更新中断函数里的步长,然后等待足够的时间让中断服务函数持续输出波形。这里的DelayMs是为了保证码与码之间的间隔符合DTMF标准,实际项目里如果发送多个码,就会把这个延时放到一个状态机里,避免阻塞主循环。
4.2 DTMF按键到频率映射的查表法
把按键映射到两个频率,工程里常用一张结构体表:
struct dtmf_map { unsigned char key; unsigned int f_row; unsigned int f_col; }; code struct dtmf_map dtmf_table[] = { {'1', 697, 1209}, {'2', 697, 1336}, {'3', 697, 1477}, {'4', 770, 1209}, {'5', 770, 1336}, {'6', 770, 1477}, {'7', 852, 1209}, {'8', 852, 1336}, {'9', 852, 1477}, {'0', 941, 1336}, {'*', 941, 1209}, {'#', 941, 1477}, }; void dtmf_play(unsigned char key, unsigned int duration_ms) { unsigned char i; unsigned long elapsed; unsigned int row_step_old, col_step_old; for (i = 0; i < sizeof(dtmf_table) / sizeof(dtmf_table[0]); i++) { if (dtmf_table[i].key == key) { row_step_old = row_step; col_step_old = col_step; dtmf_set_freq(dtmf_table[i].f_row, dtmf_table[i].f_col); for (elapsed = 0; elapsed < duration_ms; elapsed++) { DelayMs(1); } row_step = row_step_old; col_step = col_step_old; break; } } }dtmf_table用code关键字存放在程序存储器而不是RAM,这对8051架构来说非常关键,因为内部RAM只有256字节,一张12行的表如果放在RAM里会占用太多宝贵空间。dtmf_play先保存旧的步长值,播放完立即恢复,这样在没有拨号时DAC输出保持之前的电平,不会产生不可控的杂音。duration_ms参数直接控制按键音的持续时间,在电话拨号应用中通常设置80到120ms比较合适。
4.3 常见错误:DAC0输出为0或全高,中断不触发
实际调试中遇到最多的问题有两类。第一类是DAC0完全没输出,或者始终固定在一个电平。这时先查DAC0CN的最高位是否置1,很多源码把使能位写在别的初始化函数里,而调用顺序出错误导致DAC没开启。第二类是DAC输出是方波而不是正弦波,这通常是采样率设置的太低,导致每个周期只有几个点输出,波形还原不出来。先用万用表确认DAC0引脚有电压变化,再看中断函数有没有被调用,方法是在中断服务函数的第一行加一个调试断点,Keil里运行到断点就说明中断已经在触发。
另一个隐蔽问题发生在中断优先级上。C8051F020的T4中断默认优先级是低优先级,如果系统里同时有串口中断或其他高优先级中断,串口长时间工作会抢占T4中断,导致DAC输出频率漂移。解决办法是在T4CON里将T4中断优先级设为高,代价是其他中断延迟变大。在纯DTMF输出的场景里,这个代价完全可接受。
5. 示波器验证与工程优化技巧
5.1 秒级频率校验:用示波器测单个DTMF行频
代码烧录到硬件之后,第一件要做的事情是确认频率准确,而不是急着听声音。把示波器探头接到DAC输出引脚,只发送一个“1”键,触发方式选上升沿,时间轴调到每格2ms。理论上应该看到大约0.5个周期的行频波形,数出完整周期数除以时间窗长度就是实测频率。例如在10ms窗口内数出6.97个周期,说明行频实际为697Hz,完全符合预期。如果偏差超过5%,优先检查系统时钟配置——C8051F020上电默认使用内部振荡器,精度约±2%,对DTMF来说勉强可用,但最好还是切换到外部晶振并把时钟倍频到24.5MHz。
在调试过程中也可以临时在中断服务函数里加一个计数器,定时翻转某个空闲IO引脚,用示波器测量这个引脚的翻转频率,反推中断频率是否准确。IO翻转中断法比直接测DAC输出电压更容易判断问题所在:如果IO频率正常但DAC波形不对,问题在DAC配置;如果IO频率本身就偏差很大,问题在T4重载值或系统时钟源。
5.2 两级RC滤波电路与音频驱动参数
DAC0的输出理想情况下是阶梯状正弦波,采样频率的镜像频率会在频带上留下不需要的残骸。用于电话拨号场景时,需要加两级无源RC低通滤波器去除采样时钟分量:
| 滤波级 | 电阻kΩ | 电容nF | 截止频率Hz |
|---|---|---|---|
| 第一级 | 20 | 10 | 约800 |
| 第二级 | 10 | 10 | 约1600 |
第一级截止频率放在800Hz左右是为了保留完整的行频成分,同时大幅衰减8kHz采样时钟及其谐波。第二级放到1600Hz,可以进一步压制镜像频率。实际如果发现高音频(1477Hz、1633Hz)幅度明显衰减,把第一级截止频率提到1kHz,做法是换成15kΩ电阻和10nF电容,计算截止频率约为1/(2π×15k×10nF) ≈ 1061Hz。两级滤波之后接一个音频功率放大器如LM386驱动小喇叭,或者用100μF电容隔直后接耳机,就能听到清晰的DTMF音。
5.3 时域上做幅度包络,消除爆音
直接让一个正弦波突然开始、突然结束,听感上是会带“咔哒”噪声的。处理办法是在中断服务函数里加入一个包络计数器,播放开始时用10ms斜坡将幅度从0升到目标值,结束时再用5到10ms斜坡降回0。实现并不复杂,在dtmf_play开头设定一个gain变量,中断函数里每经过N个采样点递增一次gain直到达到最大值,播完后再递减。幅度控制直接作用在dac_out上:
dac_out = (dac_out * gain) >> 8; // gain 0~256这样在电话拨号音中能明显减少边沿噪声,而且不影响频率准确性。工程中如果希望音量可调,也可以用同一路子把gain换成一个由主循环更新的音量级别值,实现静音、低、高多级控制。整个DAC0_DTMF1工程从寄存器配置到中断输出再到音频调优,链路里最值钱的经验是:嵌入式C语言里,同样的功能用查询循环实现和用中断驱动实现,可维护性和系统响应完全是两个级别,而查表替代实时计算则是8051这类资源受限平台上兼顾精度与性能的通用手段。
本文还有配套的精品资源,点击获取