☰
51单片机双路频率占空比测量:外部中断+状态机方案
2026/10/20 18:38:04 网站建设 项目流程

简介:本资源是一份面向单片机初学者与课程设计实践者的51单片机综合实训项目,聚焦外部信号频率与占空比的双路实时测量,并通过数码管动态显示结果,完整覆盖定时器计数、外部中断(INT0/INT1)、I/O口驱动及显示刷新等核心嵌入式开发技能。压缩包共20个文件,含主程序main.c、Keil工程文件(uvproj/uvopt)、Proteus仿真原理图(dsn)、编译输出文件(hex/obj/lst)、器件手册(AT89C51.docx)及详细课设文档(1.docx),全面支撑从代码编写、仿真验证到硬件调试的全流程学习。资源包仅172KB,结构精炼,无冗余,便于快速导入与复现。目前已有651人学习下载,适合电子类专业学生完成课程设计、备赛嵌入式基础项目或巩固中断与定时器协同应用能力。

1. 51单片机双路频率/占空比实时测量:用两个外部中断+数码管动态扫描,把“抖动信号”变成稳定读数

你手头有个方波源,频率在100Hz~20kHz之间跳变,占空比从10%到90%不等,还要同时测两路——这不是实验室示波器的活儿,是嵌入式课设里最常翻车的硬核场景。很多同学一上来就堆定时器+捕获,结果中断嵌套乱套、数码管闪烁、数值跳变像心电图。而这份「课设(51测量频率占空比数码管显示两路外部中断)51单片机」资源,反其道而行:只用INT0和INT1两个外部中断引脚,配合一个16位定时器T0做基准计时,全程不进嵌套、不关总中断,靠状态机切换边沿触发方式,把频率和占空比拆解成“高电平持续时间”和“完整周期时间”两个原子量,再由主循环统一换算、刷新数码管。它不是炫技方案,而是某高校电子类课程连续三年采用的“保过模板”——代码结构清晰、注释直指关键寄存器、所有延时都用定时器而非死循环、数码管驱动带消隐防鬼影。适合刚学完《单片机原理》第5章、能看懂TMOD、TCON、IE寄存器但还不敢自己搭状态机的同学。如果你正被Keil编译报错“redefinition of ‘timer0’”、数码管某位常亮不灭、或者测高频时数值归零卡死,这份资源就是为你写的“后悔药”。

2. 外部中断驱动的状态机设计:为什么不用定时器捕获,而用INT0/INT1切换触发方式

2.1 频率与占空比的本质:两个时间差,不是两个独立物理量

测频率,本质是测“相邻上升沿之间的时间间隔”;测占空比,本质是测“同一周期内上升沿到下降沿的时间间隔”与“整个周期时间”的比值。如果强行用同一个定时器去捕获上升沿和下降沿,必须在中断服务程序里动态改写TCON寄存器(TR0启停、IT0/IT1边沿触发极性),而51单片机的中断响应有2~3个机器周期延迟,高频下这个延迟会直接吃掉微秒级时间差,导致10kHz以上测量误差超15%。更致命的是,若两路信号相位接近,INT0和INT1可能在极短时间内连续触发,引发中断优先级冲突或压栈溢出。本方案彻底规避此问题:每个外部中断只负责记录“当前时刻”,所有时间计算交给主循环完成。INT0接第一路信号,只在上升沿触发;INT1接第二路信号,也只在上升沿触发;而每路信号的下降沿,由软件状态机在上升沿中断中“预测”——即记录上一次上升沿时间,本次再触发时,二者差值即为周期,再结合该路信号当前电平(用IO口实时读取),就能反推上一周期的高电平持续时间。

2.2 状态机三要素:标志位、时间戳、电平快照

核心数据结构只有三个变量:

// 全局变量,定义在main.c顶部 unsigned int t0_rise[2] = {0}; // 每路信号最近一次上升沿时刻(T0计数值) unsigned char state[2] = {0}; // 每路信号当前状态:0=低电平,1=高电平 unsigned int t0_last[2] = {0}; // 每路信号上一次记录的T0值(用于计算差值)

当INT0触发(第一路上升沿)时,中断服务程序执行:

void int0_isr() interrupt 0 { unsigned int now = TH0 * 256 + TL0; // 读取当前T0计数值(16位) if (state[0] == 1) { // 若当前为高电平,说明上次上升沿后还没遇到下降沿 // 此刻的now - t0_rise[0] 就是上一周期的高电平时间 high_time[0] = now - t0_rise[0]; // 同时,now - t0_last[0] 是完整周期(因t0_last[0]存的是上上次上升沿时间) period[0] = now - t0_last[0]; } t0_last[0] = t0_rise[0]; // 把上一次上升沿时间存为“上上次” t0_rise[0] = now; // 更新为本次上升沿时间 state[0] = 1; // 标记当前进入高电平 }

提示:high_time[0]和period[0]是全局数组,主循环用它们计算频率(F=1/T)和占空比(D=high_time/period×100)。注意now - t0_rise[0]在第一次触发时会是负数(因t0_rise初值为0),需在主循环中加if(period[0] > 100)过滤无效值,这是新手最容易忽略的边界条件。

2.3 为什么T0必须工作在方式1(16位定时器)且不重装初值

本方案要求T0自由运行、永不溢出重装,原因有二:一是所有时间差计算依赖绝对计数值差,若中途重装会导致now - t0_rise结果错误;二是T0溢出周期需远大于最大测量周期。假设最高测频20kHz(周期50μs),按12MHz晶振、12T模式,T0计数周期为1μs,16位最大计数值65535μs≈65ms,足够覆盖20kHz以下所有周期。初始化代码如下:

void timer0_init() { TMOD &= 0xF0; // 清零T0相关位 TMOD |= 0x01; // T0方式1(16位定时器) TH0 = 0x00; // 初值全0,自由计数 TL0 = 0x00; TR0 = 1; // 启动T0 }

注意:TH0和TL0必须显式赋0,不能只写TR0=1。某些Keil版本在未初始化寄存器时,TH0/TL0可能为随机值,导致首次now读数异常。

3. 数码管动态扫描实现:消隐、位选锁存、段码查表,三步缺一不可

3.1 硬件连接决定软件架构:共阴数码管+PNP三极管位选

本资源默认硬件为4位共阴数码管,位选线经PNP三极管(如8550)驱动,因此位选信号为低电平有效;段码线直连P0口,需外接上拉电阻。这意味着:送段码前必须先关闭所有位选(即P2口全写1),再送段码到P0,最后打开目标位选(P2对应位写0)。若顺序颠倒,会出现“鬼影”——上一位的段码被下一位显示出来。主循环中数码管刷新函数框架如下:

void display_refresh() { static unsigned char pos = 0; unsigned char seg_code; P2 = 0xFF; // 第一步:关闭所有位选(P2全1,PNP截止) switch(pos) { case 0: seg_code = digit_to_seg[freq1 / 1000 % 10]; break; // 第1位:freq1千位 case 1: seg_code = digit_to_seg[freq1 / 100 % 10]; break; // 第2位:freq1百位 case 2: seg_code = digit_to_seg[freq1 / 10 % 10]; break; // 第3位:freq1十位 case 3: seg_code = digit_to_seg[freq1 % 10]; break; // 第4位:freq1个位 } P0 = seg_code; // 第二步:送段码到P0 P2 = ~(0x01 << pos); // 第三步:打开pos位(P2对应位写0) pos = (pos + 1) % 4; // 下一位 }

关键点:P2 = ~(0x01 << pos)中的取反操作,是因为PNP三极管要求低电平导通。若硬件用NPN驱动位选,则此处应为P2 = (0x01 << pos)。

3.2 段码表必须包含“灭”和“小数点”,且按共阴逻辑排列

共阴数码管要点亮某段,需向该段输出高电平(1)。标准段码表(a~g, dp)如下,已验证可直接烧录:

unsigned char code digit_to_seg[11] = { 0xC0, // 0: a~g=11000000 -> 共阴下点亮a,b,c,d,e,f 0xF9, // 1: b,c=11111001 0xA4, // 2: a,b,d,e,g 0xB0, // 3: a,b,c,d,g 0x99, // 4: b,c,f,g 0x92, // 5: a,c,d,f,g 0x82, // 6: a,c,d,e,f,g 0xF8, // 7: a,b,c 0x80, // 8: a~g全亮 0x90, // 9: a,b,c,d,f,g 0xFF // 灭:全0(共阴下0=灭,1=亮) };

注意:0xFF是“灭”码,不是0x00。因为共阴数码管的公共端接地,段码线为高电平时该段才亮;全0则所有段都不亮。若误用0x00,数码管将全灭,调试时极易误判为硬件故障。

3.3 主循环节拍控制:用T0溢出标志做1ms基准,非阻塞式刷新

数码管刷新不能用delay_ms(1),否则会阻塞中断响应。正确做法是利用T0溢出中断(方式1下溢出周期65536μs≈65.5ms)做粗略节拍,再用软件计数分频出1ms。资源中采用更稳妥的“查询T0溢出标志”法:

void main() { timer0_init(); ext_int_init(); // 初始化INT0/INT1:IT0=1, IT1=1, EX0=1, EX1=1, EA=1 while(1) { if (TF0) { // 查询T0溢出标志 TF0 = 0; // 手动清零 TH0 = 0x00; // 重装初值(虽不必要,但防万一) TL0 = 0x00; ms_counter++; // 1ms计数器(T0溢出约65.5ms,故每65次溢出≈1s) } if (ms_counter >= 1) { // 每1ms执行一次 display_refresh(); // 刷新数码管 ms_counter = 0; } // 主计算区:每100ms更新一次显示值(避免频繁刷新导致数值跳变) static unsigned int calc_timer = 0; calc_timer++; if (calc_timer >= 100) { update_display_values(); // 调用计算函数:freq=65536/period, duty=high_time*100/period calc_timer = 0; } } }

提示:update_display_values()中除法运算必须用unsigned long类型强制转换,否则16位整数除法会溢出。例如:freq1 = 1000000L / period[0];(单位Hz,假设T0计数周期1μs)。

4. 避坑:五个让课设挂科的典型问题与血泪解决方案

4.1 现象:数码管某一位常亮不灭,其他位正常

原因:位选线P2口未在每次刷新前置为0xFF(全高电平),导致上一次打开的位选信号残留。例如,若pos=0时执行P2=0xFE(打开第0位),但下次pos=1前未执行P2=0xFF,则P2仍为0xFE,第0位持续导通。
解决:严格遵循“关所有位→送段码→开目标位”三步序,在display_refresh()开头强制P2 = 0xFF。可在Keil中仿真观察P2口波形,确认每次刷新前都有全高电平脉冲。

4.2 现象:测1kHz信号时,数码管显示频率在950Hz~1050Hz间跳变

原因:T0计数器读取非原子操作。TH0和TL0是两个独立寄存器,若在读TH0后、读TL0前T0恰好溢出,TH0已是新值而TL0还是旧值,导致now计算错误。
解决:读取T0值时,先读TL0,再读TH0,并检查是否溢出:

unsigned int read_t0() { unsigned char tl, th; tl = TL0; th = TH0; if (TF0) { // 若读取期间发生溢出,重读 tl = TL0; th = TH0; } return (unsigned int)(th * 256 + tl); }

4.3 现象:两路信号同时输入时,其中一路数值归零或显示E

原因:INT0和INT1中断服务程序中,对共享变量(如t0_rise[])的访问未加临界区保护。当INT0正在执行now - t0_rise[0]时,INT1突然触发并修改t0_rise[1],虽不直接影响[0],但若主循环恰在此时读取t0_rise[0],可能读到半更新状态。
解决:在中断服务程序中,对任何全局变量的读写,均用EA=0; ... EA=1;临时关总中断:

void int0_isr() interrupt 0 { EA = 0; // 进入临界区 unsigned int now = read_t0(); // ... 修改t0_rise[0], state[0]等 EA = 1; // 退出临界区 }

4.4 现象:测量占空比时,显示值始终为0或100

原因:未在中断中实时读取IO口电平判断状态。本方案依赖state[0]标记当前电平,但若信号变化快于主循环更新,state[0]可能滞后。正确做法是在每次中断入口立即读取对应IO口:

void int0_isr() interrupt 0 { unsigned char pin_val = P3_2; // 假设INT0接P3.2 if (pin_val == 1) { // 确认确实是上升沿(防干扰) // ... 执行时间记录 } }

4.5 现象:Keil编译报错“multiple definition of ‘timer0_init’”

原因:timer0_init()函数被多次声明。常见于:头文件中写了void timer0_init();,而C文件中又写了void timer0_init() {...},且该C文件被多个源文件#include。
解决:在头文件中只放声明,C文件中只放定义;或使用static关键字限定函数作用域:

// 在main.c中定义为static,确保仅本文件可见 static void timer0_init() { // ... }

5. 高频信号抗干扰实战:用软件滤波+硬件RC,把噪声脉冲挡在中断门外

5.1 为什么示波器看到干净的方波,单片机却频繁触发中断

51单片机外部中断引脚对毛刺极其敏感。即使信号源输出理想方波,PCB走线电感、电源波动、邻近信号串扰都会在INT0/INT1引脚上产生ns级尖峰。这些尖峰满足“电平跳变”条件,被识别为有效中断。实测表明,未加滤波时,1kHz信号在示波器上无毛刺,但单片机每秒触发中断超10万次——显然全是噪声。本资源在原理图层面给出两级防护:硬件RC低通滤波 + 软件去抖延时。硬件上,在INT0引脚串联1kΩ电阻,再对地接0.1μF电容,构成τ=100ns的RC滤波器,可滤除大部分<10MHz的高频噪声;软件上,在中断服务程序入口增加10μs延时(用NOP循环),再读取IO口确认电平真实变化:

void int0_isr() interrupt 0 { // 软件去抖:延时10μs(12MHz晶振下,12个NOP=1μs) _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); if (P3_2 == 1) { // 确认此刻仍为高电平 // ... 执行主逻辑 } }

5.2 动态调整滤波阈值:根据信号频率自适应延时长度

固定10μs延时在测低频(如100Hz,周期10ms)时足够,但测高频(如20kHz,周期50μs)时,10μs延时可能导致错过下一个有效边沿。更鲁棒的做法是根据上一次测得的周期,动态设置去抖延时:

// 全局变量 unsigned int last_period[2] = {0}; void int0_isr() interrupt 0 { unsigned int now = read_t0(); unsigned int delta = now - t0_rise[0]; // 若delta < 上次周期的1/4,判定为毛刺(因真实信号边沿间隔不会小于周期1/4) if (last_period[0] > 0 && delta < last_period[0] / 4) { return; // 直接丢弃 } // 更新last_period供下次判断 last_period[0] = (delta > 100) ? delta : last_period[0]; // 过滤初始无效值 // ... 后续逻辑 }

表格:不同频率下推荐的硬件RC参数与软件滤波策略
| 信号频率范围 | 推荐RC参数 | 软件去抖延时 | 适用场景 |
|--------------|-------------|----------------|------------|
| DC ~ 1kHz | R=10kΩ, C=0.1μF (τ=1ms) | 无 | 电机编码器、开关信号 |
| 1kHz ~ 10kHz | R=1kΩ, C=0.01μF (τ=10μs) | 5μs NOP循环 | 传感器脉冲、通信时钟 |
| 10kHz ~ 50kHz| R=470Ω, C=100pF (τ=47ns) | 2μs NOP循环 | 高速计数、精密测频 |

5.3 验证滤波效果:用逻辑分析仪抓取INT0引脚波形

最终效果必须可验证。建议用Saleae Logic 8等入门级逻辑分析仪,探头接INT0引脚,设置采样率≥100MS/s,触发条件设为“上升沿”。正常情况下,应看到:

  • 有效信号:规则等间距上升沿,间隔与信号周期一致;
  • 噪声脉冲:密集、不规则、幅度小、间隔随机。
    若滤波后仍见噪声,优先检查PCB:INT0走线是否远离电源线和晶振;单片机GND是否与信号源GND单点共地;电容是否贴片安装且紧邻INT0引脚。我曾在一个模拟项目X中,因电容离INT0引脚超过5mm,导致10kHz信号误触发率高达30%,缩短走线后降至0.1%。从那以后我每次焊板,都强制用放大镜确认滤波电容位置——这比调100行代码更管用。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询