ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

51单片机红外遥控计算器:NEC协议解码与动态扫描显示

51单片机红外遥控计算器:NEC协议解码与动态扫描显示

简介:面向51单片机初学者、课程设计者与电子制作爱好者的红外遥控计算器实践资料,以TC9012遥控器为输入、数码管为显示终端,解决从红外信号接收、解码到四则运算输出的完整实现问题。压缩包内为1个doc文档,约245KB,正文给出可直接编译的C程序,并在文末附电路图与遥控器编码图。代码按功能拆分为定时器0与外部中断0初始化、红外码值解析Ircordpro、矩阵键值扫描keyscan、数据接收与运算DataHandle/DataOper、数码管动态显示DisplayHandle及系统初始化等模块;两个操作数与结果分别用按位数组缓存,红外引脚、定时初值与段码位定义均有注释,irdata[33]与irtime的位时序判断也作了说明,便于对照硬件逐段阅读与二次修改。目前已有588人学习下载,可作为单片机课程设计、红外解码入门与小型电子制作的参考素材。

1. 遥控器当键盘:51单片机上的红外遥控计算器到底解决了什么

按键不够用这件事,在最小系统板上特别要命。八位数码管已经占用了整个 P0 口,再想接一个 4×4 矩阵键盘,引脚立刻见底。这份基于 51 单片机的红外遥控计算器程序走的是另一条路:拿一个电视遥控器当键盘,一体化红外接收头只用一根线接到 P3.2,剩下的活全交给定时器和外部中断。整份代码不到三百行,却把收码、解码、键值映射、四则运算、动态扫描显示串成了一条完整链路,跑在一颗最普通的 51 内核单片机上。它适合两类人:手上有开发板和红外接收头、想把中断和定时器真正用起来的入门开发者;以及想给老设备加一个低成本遥控输入的产品开发者。下面按“电路怎么接、码怎么解、数怎么算、坑在哪”的顺序,把这份程序拆开来看。

2. 硬件底座:一根 P3.2 信号线拉起整台计算器

2.1 从代码反推的引脚分配

拿到一份没有原理图说明的 51 程序,第一件事是先把引脚分配还原出来。这份程序里sbit IR=P3^2;只是给外部中断引脚起了个名字,真正干活的是EX0=1; IT0=1;这两句——把 INT0 配成下降沿触发。也就是说,红外接收头的 OUT 脚必须接在 P3.2 上,接收头本身负责 38kHz 载波解调,输出的是已经拉干净的基带波形,单片机不用管载波。

显示侧更有意思。display()函数里三行位运算把 P0 口的用途暴露得很清楚:

P0 = P0 & 0x80; // 只保留 bit7,其余清零 P0 = P0 | (n << 4); // 位选放 bit4~bit6,最多 8 位 P0 = P0 | m; // 显示内容放 bit0~bit3

n是数码管位号,左移 4 位后占据 P0.4~P0.6,三位二进制刚好能编码 8 个数位;m是显示内容,直接落在 P0.0~P0.3;bit7 用&0x80单独保留,说明这一位在硬件上接了别的东西,不能被扫描逻辑踩掉。按这个写法反推,实际电路是“3 根位选 + 4 根段选”的分时复用结构,段码低 4 位直接对应 0~9 的笔段组合,中间大概率有一级译码或者干脆只点亮部分段。具体接法以夹具上的电路图为准,但引脚用途这张表是确定的:

引脚方向用途代码依据
P3.2 / INT0输入红外接收头 OUT,下降沿触发IT0=1; EX0=1;
P0.0~P0.3输出段码(4 位)P0=P0|m
P0.4~P0.6输出位选(3 位,8 个数位)P0=P0|(n<<4)
P0.7保留不被扫描逻辑改写P0=P0&0x80
XTAL1/2—决定定时器时间基准TMOD=0x02的溢出周期

这里有一个新手常忽略的点:位选和段选共用 P0 口,意味着任何一次写 P0 都必须先把上一次的位选清掉再写新的,否则会出现“上一位的段码串到这一位”的重影。程序里靠&0x80这一步做了部分清理,但它只清 bit0~bit6,如果段码译码电路有锁存,消隐还得另外处理。

2.2 TC9012 的帧长什么样

程序注释里写的是“基于 TC9012 遥控器可直接操作”,这类芯片用的是业界很常见的 NEC 格式时序。一帧完整数据由引导码、32 位数据和结束位组成:

段电平时长说明
引导码低9 ms帧开始标志
引导码高4.5 ms与低电平组成 13.5 ms 间隔
数据位 0低 + 高0.56 ms + 0.56 ms相邻下降沿间隔 1.12 ms
数据位 1低 + 高0.56 ms + 1.69 ms相邻下降沿间隔 2.25 ms
结束位低0.56 ms收尾

关键在“相邻下降沿之间的间隔”这个视角。接收头输出的波形里,每一个位的起始都是一个下降沿,所以只要用外部中断抓住下降沿,再用定时器量出两次下降沿之间过了多久,就能区分引导码、位 0 和位 1。32 位数据是低位先发:8 位地址、8 位地址反码、8 位命令、8 位命令反码。程序最后拿IRcord[2](命令字节)去查键值表,靠的就是这个排列。

2.3 定时器 0 加外部中断 0:把电平宽度量成数字

测量部分由两个中断配合完成。定时器 0 配成工作方式 2,也就是 8 位自动重装模式,只负责一件事——不停地溢出,每溢出一次全局变量irtime加一;外部中断 0 负责在下降沿到来时把这个计数值读走并清零。初始化代码如下:

void INTInit() { TMOD = 0x02; // 定时器0,方式2:8位自动重装 TH0 = 0x00; // 重装值,决定溢出周期 TL0 = 0x00; // 初值,与重装值保持一致 ET0 = 1; // 允许定时器0中断 TR0 = 1; // 启动定时器0 IT0 = 1; // INT0 下降沿触发 EX0 = 1; // 允许外部中断0 EA = 1; // 开总中断 }

方式 2 的好处是重装 automatic,中断里不用手动给 TH0/TL0 赋值,抖动小、代码短。溢出周期由晶振和重装值共同决定:

溢出周期 = (256 - TH0) × 12 / fosc

代入常见参数算一下,TH0=0、晶振 11.0592 MHz、12T 模式下,溢出周期是 256 × 1.085 μs ≈ 278 μs。再看中断服务函数:

void tim0_isr(void) interrupt 1 using 1 { irtime++; // 每 278 μs 加一,纯计数不做判断 } void ex0_isr(void) interrupt 0 using 0 { if (fir) { // 已经不是第一个下降沿 if (irtime < 49 && irtime >= 31) // 落在 8.6~13.6 ms,判为引导码 mir = 0; // 重置写指针,帧从这里开始 irdata[mir] = irtime; // 存下这一段的计数值 irtime = 0; mir++; if (mir == 33) { // 1 个引导 + 32 位,收满 irok = 1; mir = 0; fir = 0; } } else { irtime = 0; // 第一个下降沿,只作起点 fir = 1; } }

把 278 μs 这个刻度代进去,各段间隔对应的计数值是这样的:

事件实际间隔计数值判定
引导码13.5 ms48~49<49 && >=31命中
数据位 01.12 ms4小于阈值 7
数据位 12.25 ms8大于阈值 7

到这里就能明白后面if(cord>7)那个阈值的来历了——7 正好卡在 4 和 8 中间。这个数字是原作者配着他手上那颗晶振和那只遥控器调出来的,不是随便写的。换晶振、换 6T/1T 模式、换遥控器编码芯片,都必须重新算一遍,否则整段解码会集体失效。

还有两个细节值得留意。irdata[]有 33 个元素而不是 32 个,因为第 0 个位置存的是引导码间隔,Ircordpro()里从下标 1 开始处理,刚好对齐 32 位。另外fir这个标志在收满一帧后被清掉,靠它区分“这是帧头”还是“这是数据位”,逻辑简单但埋了个隐患——中途断帧时没有超时复位,这个问题留到后面避坑章细说。

3. 解码链路:33 个时间槽怎么变成 4 个字节

3.1 Ircordpro 的位拼装

外部中断只负责把 33 个时间槽攒进irdata[],真正的位判定和字节拼装在Ircordpro()里完成。这段代码是整份程序里最值得逐行读的部分:

void Ircordpro(void) { unsigned char i, j, k; unsigned char cord, value; k = 1; // 跳过 irdata[0] 的引导码 for (i = 0; i < 4; i++) // 4 个字节 { for (j = 1; j <= 8; j++) // 每字节 8 位 { cord = irdata[k]; // 取出这一位对应的定时器计数 if (cord > 7) // 大于阈值判为 1 value = value | 0x80; // 先填最高位 else value = value; // 判为 0,最高位留空 if (j < 8) value = value >> 1; // 右移,为下一位腾位置 k++; } IRcord[i] = value; value = 0; // 清空,准备下一个字节 } if ((IRcord[0] = ~IRcord[1]) && (IRcord[2] = ~IRcord[3])) { X = IRcord[2]; // 取命令字节作为键值 fang2 = 1; // 解码完成标志 } }

这段拼装用的是“先填高位再右移”的技巧。NEC 协议是低位先发,收到的第一位要落到字节的最低位,所以每收一位先按需把 0x80 置上,然后整体右移一格,让刚置的那一位往下走。循环到第 8 位时不再右移,最后一位正好停在 bit0。变量说明:cord是单个时间槽的计数值,value是本字节的临时累加器,k是irdata[]的遍历下标,i是字节序号。四个字节依次是地址、地址反码、命令、命令反码,X拿到的是命令字节,后面键值扫查直接switch(X)。

3.2 那一行反码校验

if ((IRcord[0] = ~IRcord[1]) && (IRcord[2] = ~IRcord[3]))这行是整份代码里最容易被误读的地方。它在语义上想表达的是“地址和命令各自与自己的反码互为取反,校验通过才认为这帧有效”,但写成了单等号赋值,效果就变成了:无条件把~IRcord[1]赋给IRcord[0]、把~IRcord[3]赋给IRcord[2],然后判断这两个值是否非零。由于按位取反的结果几乎不可能是 0(除非原值是 0xFF),这个条件恒真。

它之所以能跑,是因为数据正常情况下IRcord[2]本来就等于~IRcord[3],赋值前后值不变,等于白写一遍。但一旦收到脏数据或者半帧错位,这行代码不会拦住,反而会把一个凭空构造出来的值当成有效键值送进switch。正确写法是把赋值换成比较:

if ((IRcord[0] == (unsigned char)~IRcord[1]) && (IRcord[2] == (unsigned char)~IRcord[3])) { X = IRcord[2]; fang2 = 1; }

括号里的强制类型转换不能省。~IRcord[1]会先整型提升再取反,结果可能超出 8 位,跟unsigned char直接比较时高位会参与运算,某些编译器下永远不相等。加上(unsigned char)把结果截回 8 位再比,才稳。

3.3 keyscan 的键值映射与双标志握手

解码完成后,键值到功能的映射在keyscan()里完成。它用两个标志做握手:fang1表示“主循环拿到了新的红外帧”,fang2表示“解码确实产出了有效键值”,两者都到位才进switch。

void keyscan() { if (irok) { Ircordpro(); irok = 0; fang1 = 1; } // 第一段:解码 if ((fang1 == 1) && (fang2 == 1)) // 第二段:双标志放行 { fang1 = 0; fang2 = 0; switch (X) { case 0x08: key=0; flag=1; break; // 数字 0~9 case 0x01: key=1; flag=1; break; case 0x02: key=2; flag=1; break; case 0x03: key=3; flag=1; break; case 0x05: key=4; flag=1; break; case 0x06: key=5; flag=1; break; case 0x07: key=6; flag=1; break; case 0x09: key=7; flag=1; break; case 0x0A: key=8; flag=1; break; case 0x0B: key=9; flag=1; break; case 0x10: key=10; flag1=1; display(7,0); break; // 加号 case 0x1A: key=11; flag1=1; display(7,0); break; // 减号 case 0x16: key=12; flag2=1; Init(); break; // 清零 case 0x11: key=13; flag2=1; DataOper(); break; // 等号 case 0x15: key=14; flag1=1; display(7,0); break; // 乘号 case 0x19: key=15; flag1=1; display(7,0); break; // 除号 } } }

键值表是用遥控器实测出来的,不能靠猜。不同批次的遥控器即使都用同类编码芯片,命令字节也可能不一样,所以换遥控器时这张表必须重抓。映射关系整理如下:

命令字节key功能副作用
0x08 / 0x01~0x03 / 0x05~0x07 / 0x09~0x0B0~9数字输入flag=1通知 DataHandle 取数
0x1010加flag1=1切到第二操作数
0x1A11减flag1=1
0x1514乘flag1=1
0x1915除flag1=1
0x1113等号flag2=1,调用 DataOper
0x1612清零先置flag2=1再调 Init

最后一行是个典型陷阱:flag2=1先执行,Init()里又把flag2清零,最终状态回到“输入第一个数”,功能上是清零没问题,但这个顺序依赖非常脆弱,后面的避坑章会专门讲。

4. 运算内核:三个数组把一个计算器撑起来

4.1 DataHandle 的输入分流

整个计算器的数据流用三个 8 元素数组承载:DATA1[]存第一个操作数的每一位,DATA2[]存第二个,RESUIT[]存结果的每一位。配套两个游标px1、px2分别记录两个操作数已经输入了多少位。DataHandle()负责把按键落到正确的数组里:

void DataHandle() { if ((key < 10) && (flag == 1) && (flag1 == 0)) { DATA1[px1] = key; // 还没按运算符,进第一个数 px1++; flag = 0; // 消费掉这次按键 } else if ((key < 10) && (flag == 1) && (flag1 == 1)) { DATA2[px2] = key; // 已按运算符,进第二个数 px2++; flag = 0; } else if (flag1 == 1) { switch (key) { case 10: f1=1; f2=0; f3=0; f4=0; break; // 加 case 11: f1=0; f2=1; f3=0; f4=0; break; // 减 case 14: f1=0; f2=0; f3=1; f4=0; break; // 乘 case 15: f1=0; f2=0; f3=0; f4=1; break; // 除 } } }

三个分支的判据全靠标志位组合。flag是“有新数字键待处理”的一次性信号,处理完立刻清零;flag1是“已经进入第二操作数阶段”的持久状态;f1~f4是四个运算符的互斥标志。数组按输入顺序存数,DATA1[0]是最高位,DATA1[px1-1]是最低位,这个顺序决定了后面还原十进制数时要从高位开始乘 10。

注意这里没有对px1、px2做上限检查,理论上连按十次数字键就会越界写DATA1[8]之后的内存。实际使用中 8 位数码管最多显示 8 位,用户不太会连按超过 8 次,但从代码健壮性看这是个该补的洞——在自增前加一句if (px1 < 8)就能挡住。

4.2 DataOper 的十进制还原

按等号触发DataOper(),它要完成三件事:把两个位数组还原成十进制数、按运算符算出结果、把结果拆回位数组。还原部分的逻辑是标准的秦九韶算法:

void DataOper() { uchar i, j, m = 0; lint k = 0; for (i = 0; i < (px1 - 1); i++) // 第一操作数,最高位到次低位 { k = DATA1[i]; for (j = 0; j < (px1 - i - 1); j++) k = k * 10; // 当前位乘 10 的幂 x1 = x1 + k; } x1 = x1 + DATA1[px1 - 1]; // 最后一位单独加,避免乘 10^0 的循环 for (i = 0; i < (px2 - 1); i++) // 第二操作数,同法处理 { k = DATA2[i]; for (j = 0; j < (px2 - i - 1); j++) k = k * 10; x2 = x2 + k; } x2 = x2 + DATA2[px2 - 1]; if (f1 == 1) y = x1 + x2; // 加 else if (f2 == 1) y = x1 - x2; // 减 else if (f3 == 1) y = x1 * x2; // 乘 else if (f4 == 1) y = x1 / x2; // 除 RESUIT[0] = y / 10000000; RESUIT[1] = (y % 10000000) / 1000000; RESUIT[2] = (y % 1000000) / 100000; RESUIT[3] = (y % 100000) / 10000; RESUIT[4] = (y % 10000) / 1000; RESUIT[5] = (y % 1000) / 100; RESUIT[6] = (y % 100) / 10; RESUIT[7] = y % 10; while (!RESUIT[m]) m++; // 跳过前导零 px3 = m; // 结果的有效起始位 }

x1、x2声明成unsigned long,是为了容纳 8 位数乘 8 位数这种量级;y同样是unsigned long。k是每个位展开时的临时乘积累加器,复用而不重新初始化,因为循环里第一句k = DATA1[i]会覆盖掉上一轮的值。减法那一路用的是无符号数,x1 < x2时会绕回一个接近 2³² 的大数,这是这份代码的已知边界,实际用的时候要么先判大小再决定顺序,要么加个符号位单独记。

除法那一路还有更明显的风险:x2为 0 时直接触发除零,51 内核没有异常机制,结果不可预测。加一句if (x2 == 0) { /* 显示错误码 */ return; }是必须的,这条后面避坑章还会展开。

4.3 RESUIT 的位数分离与前导零

结果拆分用的是逐位取余,RESUIT[0]是千万位,RESUIT[7]是个位,从高到低排。这种写法的好处是不用循环、不用除法器库,编译出来就是几条 DIV 指令,缺点是只能表达最多 8 位,超过就静默截断。

while (!RESUIT[m]) m++;这一行用来跳过前导零,让数码管只显示有效位。逻辑本身没问题,但它没有边界判断——如果计算结果是 0,RESUIT[]里全是 0,m会一路加下去读到数组外面。RESUIT只有 8 个元素,m到 8 就开始读栈上的其他变量,px3拿到一个随机值,显示阶段for (i=7; i>=px3; i--)的循环次数跟着失控。这是整份代码里最容易复现的跑飞点,改法很简单:

for (m = 0; m < 7 && RESUIT[m] == 0; m++) // 最多扫到个位就停 ; px3 = m;

上限取 7 而不是 8,是保证至少留一位显示,结果为 0 时也能正常点亮一个“0”。

4.4 display 与 DisplayHandle 的动态扫描

显示部分分两层。底层display(n, m)只做一件事:把位号n和内容m拼进 P0 口。上层DisplayHandle()根据当前处于哪个阶段,决定把哪个数组的内容刷上去:

void DisplayHandle() { uchar i, x; if ((flag1 == 0) && (flag2 == 0)) // 阶段一:显示 DATA1 { x = px1; if (px1 == 0) display(7, 0); // 空状态显示一个 0 else for (i = 0; i < px1; i++) { display(8 - x, DATA1[i]); x--; delay(5); } } else if ((flag1 == 1) && (flag2 == 0)) // 阶段二:显示 DATA2 { x = px2; if (px2 == 0) display(7, 0); else for (i = 0; i < px2; i++) { display(8 - x, DATA2[i]); x--; delay(3); } } else if ((flag1 == 1) && (flag2 == 1)) // 阶段三:显示 RESUIT { for (i = 7; i >= px3; i--) { display(i, RESUIT[i]); delay(3); } } }

三个阶段用flag1、flag2两个标志编码,等价于一个两 bit 的状态机。x是递减的位号计数器,从px1开始往下走,配合8 - x把最早输入的最高位放到最左边的数码管上,实现“数字从右往左挤”的效果。px3是结果的有效起始位,从 7 倒着刷到px3,前导零自然不显示。

扫描节奏是这里的性能瓶颈。每点亮一位就delay(5),8 位刷一遍要 40 ms,帧率只有 25 Hz,肉眼能看出轻微闪烁;第二、三阶段用delay(3)好一些,但也只有 40 Hz 左右。delay()本身的实现也很粗:

void delay(msx) { uint i, j; for (i = 0; i <= msx; i++) for (j = 0; j <= 110; j++); }

内层 110 次空循环在 12 MHz、12T 下大约 1 ms,但编译器优化等级一改就全乱,而且函数参数msx连类型都没写,走的是 K&R 默认 int。要稳,建议把延时压到 1~2 ms,或者干脆去掉延时、靠主循环本身的执行时间做扫描节拍——主循环里keyscan()、DataHandle()都是纯判断,跑得比 1 ms 快得多,扫出来的帧率反而更高、更均匀。

5. 避坑与排查:红外没反应、数码管重影、结果错位逐个对

5.1 遥控器按了没反应,或者所有键都出来同一个数

现象:程序烧进去,接收头的指示灯在按遥控器时会闪,说明硬件通了,但数码管毫无变化;或者更诡异,无论按哪个键,显示的数字都一样。

原因基本锁定在时间基准上。ex0_isr()里判断引导码用的是irtime < 49 && irtime >= 31,位判定用的是cord > 7,这两组阈值都是按“定时器溢出周期 ≈ 278 μs”算出来的。换了晶振(比如从 11.0592 MHz

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

返回列表