AT89C51外部中断声光报警器:从寄存器到定时器优化
简介西南科技大学单片机课程设计2报告文档面向修读单片微机原理及应用课程的计算机等专业学生聚焦AT89C51外部中断应用帮助读者把中断理论梳理为一份规范的课程设计成果。压缩包内为单个docx文件约477KB正文覆盖知识要求与能力要求、声光报警器设计目标与任务、电路原理图设计、主程序与中断服务程序流程图以及带注释的关键代码。设计任务用2个发光二极管、2个按键、1个数码管和1个蜂鸣器构成声光报警器实现按键触发后数码管显示、报警灯闪烁与蜂鸣器报警的联动代码部分含头文件引用、宏定义、变量声明和中断服务程序并延伸到K3、K4按键控制数码管显示3、4的实现。读者可从中借鉴报告的组织框架、IE与IP寄存器设置思路以及中断法与查询法的差异说明。目前已有89人学习适合作为课程设计撰写与中断程序调试的对照样例。1. 一份能直接跑通的中断实验AT89C51 声光报警器拆给你看单片机课程设计里最容易被糊弄过去的一类题目就是外部中断。很多人交上去的 AT89C51 外部中断程序本质上是把按键扫描塞进中断函数再用一串死延时拖住 CPU仿真里灯确实在闪可一问「中断标志什么时候清」「IE 和 IP 到底差在哪」就答不上来。这份单片机课程设计 2 的资源包正好卡在这个位置上4 个按键、4 个发光二极管、1 个数码管、1 个蜂鸣器两路走外部中断、两路走主循环查询报告册里原理图、流程图、段码推导、Keil 源码、仿真结论都齐全。适合正在赶课程设计、需要一份能复现底稿的人也适合代码已经写完、但说不清中断服务程序为什么这么写的人。2. 中断体系先立住IE、TCON、IP 三个寄存器怎么配拿到这份代码第一眼看到的四行初始化是EA 1; EX0 EX1 1; IT0 IT1 1;。这四行背后是三个寄存器在协同工作搞不清哪一位管什么改代码就只能是碰运气。这一章把 51 单片机的中断骨架拆开顺便补上原报告里「要求掌握但代码没用」的 IP 优先级部分。2.1 中断源、标志位与中断号interrupt 0 和 interrupt 2 从哪来AT89C51 一共 5 个中断源每个中断源在 C51 里对应一个固定的中断号这个号不是随便定的编译器靠它把函数地址放到对应的向量入口上。写错一个数字函数永远不会被调用而且编译不报错这是新手最常见的翻车方式。中断源触发标志C51 中断号向量地址对应引脚/条件外部中断 0IE0TCON.100003HP3.2 / INT0定时器 0TF0TCON.51000BHT0 溢出外部中断 1IE1TCON.320013HP3.3 / INT1定时器 1TF1TCON.73001BHT1 溢出串口RI / TISCON40023H收到/发完一帧原代码里void int0() interrupt 0和void int1() interrupt 2用的就是这张表。注意外部中断 1 的中断号是 2 不是 11 被定时器 0 占了这个「跳号」是很多人第一次写中断时踩的坑。标志位的清除方式要分触发方式讨论。在下降沿触发IT01下IE0 由硬件在进入中断服务程序时自动清零不用你管在低电平触发IT00下标志位跟随引脚电平只要外部还保持低电平中断就会反复进入。这一点直接决定了第 5 章里「按一下触发好几次」的现象先记住结论按键类输入一律用下降沿触发。还有一条硬规则P3.2 和 P3.3 一旦被配成外部中断输入就不能再当普通 IO 口读写了。原代码把 K3、K4 放在 P3.4 和 P3.5 上正好避开了这两个引脚这个细节在原理图上必须体现画错的话仿真里按键永远读不到。2.2 初始化那四行代码逐位拆开看把寄存器按位摊开EA、EX0、IT0这些名字就都有了位置。IE 寄存器管「允不允许」TCON 管「怎么触发、标志在哪」。IE : EA - - ES ET1 EX1 ET0 EX0 位号: 7 6 5 4 3 2 1 0 TCON: TF1 TR1 TF0 TR0 IE1 IT1 IE0 IT0 位号: 7 6 5 4 3 2 1 0按这个位图初始化代码可以写成更好读的版本顺便把定时器时基的位也预留出来/* 外部中断 0/1 初始化下降沿触发逐位赋值便于对照寄存器手册 */ void ext_int_init(void) { EA 1; /* IE.7 全局中断总开关不开这一位下面全白配 */ EX0 1; /* IE.0 允许外部中断 0来自 P3.2 */ EX1 1; /* IE.2 允许外部中断 1来自 P3.3 */ IT0 1; /* TCON.0 1下降沿触发 0低电平触发 */ IT1 1; /* TCON.2 同上按键必须选下降沿 */ }逻辑说明EA是总闸任何中断要生效都必须先置 1很多「中断不响应」的问题最后查出来就是漏了这一句或者在某处被意外清零。EX0/EX1是分闸按需打开。IT0/IT1决定触发方式写 1 之后引脚上出现高到低的跳变才会置位 IE0/IE1。参数说明这几行没有可调参数全是布尔位。真正需要你根据硬件改的是触发方式——如果按键电路是低电平有效并且外部有上拉下降沿触发最稳如果电路里没有上拉电阻引脚悬空时电平乱跳边沿触发也会误触发这时候要先补硬件。中断服务程序的骨架也顺带说一下。C51 编译器会自动在函数入口压栈 ACC、PSW、B、DPH、DPL 等寄存器出口再弹回来所以普通变量不需要手动 PUSH/POP但如果你在中断和主循环里同时操作同一个全局变量比如time就得加volatile并且注意原子性8 位机上一个unsigned int的读写不是原子操作。中断函数还有两条硬约束不能有返回值不能带参数。写成void int0(void) interrupt 0是唯一正确的形式。2.3 IP 优先级报告里没用上但答辩必问原报告的「知识要求」里明确写了要掌握 IP 寄存器可代码从头到尾没碰过它。这不是错误只是把加分点空着。IP 的位图长这样IP : - - - PS PT1 PX1 PT0 PX0 位号: 7 6 5 4 3 2 1 0默认全部为 05 个中断处于同一优先级此时谁也不能打断谁两个中断同时挂起时按内部查询顺序响应外部中断 0 → 定时器 0 → 外部中断 1 → 定时器 1 → 串口。也就是说INT0 天生排在 INT1 前面。把PX0置 1 之后外部中断 0 升为高优先级它可以打断正在执行的 INT1 服务程序反过来不行。这个机制只在两种场景下有意义两个中断几乎同时到来或者一个中断服务程序执行时间较长、期间另一个中断必须立刻响应。放到这个声光报警器上如果 K1 代表「紧急报警」、K2 代表「普通提示」把PX0 1就说得通了K2 的 2 秒报警过程中按下 K1K1 能立刻抢占总线。代码只多一行PX0 1; /* IP.0 外部中断 0 设为高优先级可打断 INT1 服务程序 */需要提醒的是优先级不能滥用。高优先级中断里如果还放长延时被打断的低优先级中断就会被拖得更久效果和没设优先级差不多。优先级解决的是「谁先跑」不解决「跑多久」后者要靠第 6 章的定时器时基方案。另外中断嵌套层数越多栈占用越大AT89C51 只有 128 字节 RAM 做栈区嵌套两层以上就要盯着栈指针了。3. 数码管段码不是玄学0xF9、0xA4、0xB0、0x99 的推导与验证原报告第八节记录了一个很典型的困惑数码管为什么写0xf9就显示 1。这段推导其实包含三个独立的知识点——极性、段序、进制转换任何一个搞错显示出来的都是乱码。这一章把这套推导固化成可以复用的表和方法。3.1 先定极性再谈段码七段数码管内部是 7 个加小数点是 8 个LED要么共阳、要么共阴这两种接法下段码是反的混用必然全错。共阳数码管所有 LED 的阳极连在一起接 VCC某个段引脚给低电平0时该段点亮。共阴数码管所有阴极连一起接 GND段引脚给高电平1才点亮。原代码P0 0xf9显示 1而 1 只需要 b、c 两段亮。0xF9 展开成二进制是1111 1001最低两位是 0正好对应 b、c 为低电平——所以这是共阳数码管P0 口输出低电平点亮对应段。这个判定方法很实用随便找一个段码看 0 的位置对应哪些段能对上就是共阳对不上就按共阴重新算。段序也要先约定。行业默认 P0.0 接 a 段、P0.1 接 b 段一直到 P0.6 接 g 段、P0.7 接小数点 dp。实际接线时如果顺序接反了段码就得重新排最省事的做法是画原理图时按这个顺序连后面所有表都能直接抄。3.2 手算 1 到 4 的段码并落成表把「亮哪些段」翻译成「哪些位是 0」再转十六进制这个过程走一遍就再也不会忘。位序按dp g f e d c b a从高到低排列。数字亮的段二进制dp g f e d c b a十六进制1b、c1111 10010xF92a、b、d、e、g1010 01000xA43a、b、c、d、g1011 00000xB04b、c、f、g1001 10010x99拿 2 验算一遍需要 a、b、d、e、g 五段亮对应位是 bit0、bit1、bit3、bit4、bit6 置 0其余置 1得到1010 0100高四位 1010 是 A低四位 0100 是 4合起来 0xA4。3 是 a、b、c、d、g 亮1011 0000高四位 B、低四位 0即 0xB0。4 是 b、c、f、g 亮1001 1001即 0x99。三个都和原代码对得上说明这套位序约定和硬件接线是一致的。顺手把 0、5 到 9 也补齐做成完整表课程设计答辩时评委经常会顺手问一句「显示 8 呢」。3.3 用查表替掉代码里的魔法数字原代码里P0 0xf9;、P0 0xa4;这样的写法散落在各个中断函数里能跑但可读性差改一位数字就要回头查半天。工程上的常规做法是提成常量表用数字做索引。/* 共阳数码管段码表下标即显示的数字0xFF 表示全灭 */ const unsigned char SEG_CODE[10] { 0xC0, /* 0: a b c d e f */ 0xF9, /* 1: b c */ 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: 全亮 */ 0x90 /* 9: a b c d f g */ }; #define SEG_OFF 0xFF /* 全灭共阳下所有段给高电平 */逻辑说明表里每个元素就是一段固定的端口输出值显示时直接P0 SEG_CODE[2];语义清晰也不会出现「改一位忘了改另一处」的问题。SEG_OFF单独定义成宏是因为「熄灭」这个语义在代码里出现频率很高用宏比再写一个 0xFF 更容易搜到。参数说明这张表只在共阳接法、段序为 a→P0.0 时成立。换共阴数码管整张表按位取反即可0xC0 变 0x3F0xF9 变 0x06不用重新推导。换端口不换段序表原样复用。还有两个容易忽略的硬件点。P0 口作为标准 51 的开漏输出内部没有上拉电阻驱动共阳数码管时必须外接 10k 排阻到 VCC否则段电流上不去仿真里看着能亮实物上暗淡甚至不亮。另外每个段要串限流电阻常见取 330Ω1kΩ取值太小会烧段、太大亮度不够按 5V 供电、每段 510mA 估算是比较稳妥的起点。4. 把代码跑起来Keil 编译到 Proteus 仿真的落地步骤报告写得再漂亮跑不起来就是废纸。这一章按「建工程 → 编代码 → 连电路 → 分步验证」四条走一遍中间给出可以照着敲的命令和连线表顺带说清每个元件参数为什么这么选。4.1 工程建立与编译配置常规路径是 Keil C51新建工程 → 器件选 AT89C51 → 新建.c文件加入工程 → 在 Options for Target 的 Output 页勾选 Create HEX File → 晶振频率填 12MHz。最后这一步别偷懒delay_nms里的 120 次空循环是按 12MHz 估的改成 11.0592MHz 之后延时整体偏移约 8%闪烁节奏会变报警音调也会变。我一般在命令行再跑一遍语法检查比每次开 IDE 快得多也方便对比不同版本的差异# 用 SDCC 做命令行编译产物是 Intel HEX可直接喂给仿真器 sdcc -mmcs51 --model-small \ --iram-size 128 \ -o build/alarm.ihx src/alarm.c # SDCC 输出的是 ihx 格式转成烧录/仿真通用的 hex packihx build/alarm.ihx build/alarm.hex逻辑说明-mmcs51指定 51 内核--model-small表示变量默认放在片内 RAM对应 Keil 的 small 模式--iram-size 128告诉编译器片内 RAM 只有 128 字节超出会直接报错而不是悄悄溢出。第二步的packihx是把 SDCC 自己的 ihx 格式转成标准 Intel HEXProteus 只认后者。参数说明如果代码里用了interrupt关键字SDCC 的写法是void isr(void) __interrupt(0)和 Keil 的interrupt 0位置不同两边切换时要改。.ihx和.hex都放build/目录源码放src/这个结构在课程设计交作业时比一堆文件堆在桌面要体面得多。4.2 电路连线每个元件的参数都有理由原理图不是把引脚连上就行电平方向错了代码逻辑就得整个反转。按原代码的行为反推连线应该是这样的元件连接电平逻辑参数与说明D1D4P2.0P2.3低电平点亮代码里D11是熄灭串 470Ω 限流阳极经电阻到 VCC蜂鸣器P1.6翻转驱动buzz!buzz无源蜂鸣器需方波有源则只给电平K1、K2P3.2、P3.3按下拉低下降沿触发外部 10k 上拉到 VCC按下接 GNDK3、K4P3.4、P3.5查询方式低电平有效同样需要上拉和软件消抖数码管P0.0P0.7共阳低电平点亮P0 口必须外接 10k 排阻蜂鸣器这一行特别值得说。代码里buzz !buzz;每 200ms 翻转一次频率 2.5Hz这个频率推无源蜂鸣器只会发出「咔、咔」的机械声不会响。原报告在仿真里看着「报警」其实是无源蜂鸣器被当成了有源用。要让无源蜂鸣器真正响得给它 24kHz 方波这必须靠定时器中断产生主循环里翻端口翻不出来。反过来如果你的蜂鸣器模块自带振荡电路有源那buzz 1就响、buzz 0就停代码里翻转反而会变成断续的「嘀嘀」声。两种器件的驱动代码不通用先拿万用表或者直接上电试一下再决定。LED 那边也有个细节代码里D1 1表示熄灭说明是灌电流驱动单片机引脚拉低电流从 VCC 经 LED 流入引脚。这种接法下单片机引脚承受的是灌电流51 的 P2 口单脚灌电流能力约 15mA串 470Ω 时电流约 7mA安全。如果接成拉电流引脚输出高电平点亮亮度会明显偏暗因为 51 的拉电流能力只有几十微安级别。4.3 分步验证一次只加一个变量把全部代码烧进去一次点亮当然爽但一旦不工作你面对的是「代码 电路 仿真配置」三个未知数。我的习惯是拆成四步每步只验证一件事。第一步主循环里只写D1 0;其余全删确认 LED 能亮、限流电阻没接反。这一步排掉的是硬件接线问题。第二步加上 K3、K4 的查询扫描串口或者直接看数码管确认按键能读到重点看消抖延时够不够。第三步接上外部中断把 K1 的中断服务程序缩成「只翻转 D1 一次」按下按键看 LED 是否翻转一次——翻转多次就是触发方式或者抖动的问题一次都不翻就是 IE/TCON 配错。第四步再把闪烁循环和蜂鸣器加回来。这个顺序的价值在于任何一步失败可疑范围都只有上一小步引入的内容。按原报告那样整体调试遇到「按下 K1 没反应」时你没法区分是中断没进来、还是进来了但延时函数卡死、还是 LED 接反了。5. 排查清单五个翻车现场的现象、原因与改法这份代码能跑但它是典型的「课程设计能过、工程上不能用」的写法。下面五条是我在类似设计里反复见到的现场每条都按现象、原因、解决三段走对着现象查基本能定位。5.1 中断相关的三个坑坑一按下 K1 之后其他按键全部失灵两秒。现象很直观——D1 在闪、蜂鸣器在响但这时候按 K3 毫无反应等闪完 10 次才恢复。原因是int0()里那个for(time0; time10; time)循环每轮延时 200ms总计约 2 秒这段时间 CPU 全在中断服务程序里打转主循环的按键扫描根本没机会执行。中断服务程序的第一原则是「短」凡是超过几百微秒的逻辑都不该放在里面。改法有两条路一是把闪烁降级成「置标志位」中断里只alarm_flag 1;就返回闪烁交给主循环做二是干脆不用外部中断四个按键统一走查询第 6 章的定时器方案就是这个思路。坑二手指刚碰到按键报警就连续触发好几次。现象是数码管闪一下 1、闪一下灭蜂鸣器乱叫。原因是IT0 0低电平触发加上按键机械抖动触点弹跳期间引脚电平反复跳变每跳一次就进一次中断即便设成边沿触发抖动也会产生多个下降沿。改法是三层叠加硬件上在按键两端并 0.1μF 电容软件上把IT0/IT1置 1 改用下降沿触发中断服务程序入口再做一次延时确认。课程设计里前两条做到基本就够了。坑三第一次按 K2 有反应之后再按就没动静了。这种「只灵一次」的现象八成是电平触发方式下外部电平没撤销——按键卡住、或者电路里按下后一直保持低电平IE1 标志被硬件反复置位中断持续挂起主程序被淹没。也可能相反是while (K4 0);这句死等把主循环卡住了按键松开前程序停在原地。改法改用边沿触发并且把「等待按键释放」这种阻塞语句换成状态机用「上次电平」和「当前电平」做边沿判断主循环永远不阻塞。5.2 显示与声音相关的两个坑坑四数码管显示的是乱码或者该亮 a 段结果亮了 f 段。三种可能按概率排序段序接反P0.0 接到了 g 段而不是 a 段、共阳共阴搞错整张表要按位取反、P0 口没接上拉排阻导致电平拉不上去。定位方法很干脆——把P0 0xFE;写死只让最低位为 0看哪一段亮亮的那段就是接在 P0.0 上的段对照实际接线就能确认段序。这个「单段点亮法」比对着表猜快得多。坑五蜂鸣器只有「咔」的一声没有连续报警音。前面分析过buzz !buzz配合 200ms 延时产生的是 2.5Hz 方波推无源蜂鸣器只能听到机械振动声。要发出人耳可闻的报警音需要 24kHz 方波也就是每 125250μs 翻转一次引脚这个精度靠delay_nms做不到必须用定时器中断。如果手上是有源蜂鸣器那就别翻转直接buzz 1保持住松开时清零代码反而更简单。判断手上是哪种给蜂鸣器两端加 3V 直流响的是有源不响的是无源。提示这五条里「中断里放长延时」和「电平触发遇上抖动」是最容易在答辩现场被追问的两条改完代码记得把改法写进报告的「问题与解决」一节比只列现象要有分量。6. 进阶用定时器时基替掉死延时让闪烁和按键互不干扰把中断服务程序里的for循环拿掉是这份代码从「能演示」到「能讲清楚」的关键一步。做法不复杂用定时器 0 每 1ms 产生一次中断中断里只做计数主循环根据计数值决定什么时候翻转 LED、什么时候关蜂鸣器。整条链路上没有任何一处阻塞四个按键在任何时刻都能响应。先配定时器。12MHz 晶振下机器周期是 1μs方式 1 是 16 位计数器1ms 需要 1000 个机器周期初值就是 65536−1000 64536 0xFC18。/* 定时器 0 初始化方式 11ms 中断一次 12MHz */ void t0_init(void) { TMOD 0xF0; /* 高四位保留给 T1低四位清零 */ TMOD | 0x01; /* T0 方式 116 位定时器 */ TH0 0xFC; /* 初值高字节 64536 8 */ TL0 0x18; /* 初值低字节 64536 0xFF */ ET0 1; /* 允许 T0 中断 */ TR0 1; /* 启动计数 */ } /* 1ms 时基中断里只累加计数不做任何延时和 IO 翻转 */ volatile unsigned int ms_tick 0; void t0_isr(void) interrupt 1 { TH0 0xFC; /* 方式 1 需要手动重装初值 */ TL0 0x18; ms_tick; /* 溢出一次加 1主循环据此判断时间 */ }逻辑说明TMOD的低四位管 T0高四位管 T1所以先用 0xF0清掉低四位再或上 0x01避免影响 T1 的配置。方式 1 不会自动重装初值中断里必须重新写 TH0/TL0漏写的话下一次溢出的时间会变成 65.536ms 而不是 1ms闪烁节奏直接乱掉。ms_tick用volatile修饰因为它会被中断和主循环同时访问不加的话编译器可能把它优化进寄存器主循环读到的是旧值。主循环这边用「时间戳比较」代替延时/* 非阻塞闪烁记录上一次动作的时刻到点才翻转 */ unsigned int t_led 0, t_beep 0; bit alarm1 0, alarm2 0; void main(void) { ext_int_init(); t0_init(); while (1) { /* 报警 1LED 每 200ms 翻转蜂鸣器每 5ms 翻转约 100Hz 方波 */ if (alarm1) { if (ms_tick - t_led 200) { t_led ms_tick; D1 !D1; } if (ms_tick - t_beep 5) { t_beep ms_tick; buzz !buzz; } } /* 报警 2 的逻辑同理用独立的 t_led2 / t_beep2 */ } }参数说明200决定 LED 闪烁快慢改小闪得快5决定蜂鸣器音调5ms 翻转一次对应 100Hz 方波落在无源蜂鸣器能响的区间想更尖锐可以调到 23ms。ms_tick - t_led这种减法写法即使ms_tick溢出回绕也不会出错这是无符号数运算的特性比直接比较大小安全。中断函数里只保留一句alarm1 1;2 秒后由主循环把标志清掉CPU 再也没有被独占的时刻。改完之后对比一下前后差异答辩时这张表比讲十分钟原理都管用对比项原报告写法定时器时基写法中断服务程序耗时约 2 秒约 10 微秒报警期间按其他键无响应立即响应蜂鸣器音调2.5Hz基本不响可调100Hz 起闪烁周期精度依赖空循环随晶振漂移依赖定时器误差 1%有个坑我踩过把ms_tick定义成unsigned char的时候它 255ms 就回绕一次200ms 的比较逻辑会变得时快时慢查了半天才想起来是位宽问题。从那以后我每次定义时间戳变量都强制走一遍「这个变量最大要数到多少」的估算超过 255 一律用unsigned int宁可多占一个字节。希望帮到你。本文还有配套的精品资源点击获取