☰
51单片机红外遥控小车源码解析:编码、解码与STM32移植指南
2026/10/21 19:34:49 网站建设 项目流程

简介:这是一份基于STC/51内核单片机与STM32平台的红外遥控小车程序PDF文档,面向嵌入式硬件初学者、单片机爱好者及参加电子设计竞赛的学生,帮助读者理解红外编码发送、按键扫描、定时中断与小车驱动控制之间的完整协作流程。文档以附录形式给出可直接参考的C语言与汇编代码,覆盖红外发射协议、解码逻辑、延时函数及液晶显示驱动等关键模块,适合对照学习与二次开发。包体仅含1个PDF文件,大小约215KB,内容紧凑、便于离线阅读。目前已有196人学习浏览。读者可从中获取完整的红外遥控编码与收发实现思路,了解如何通过定时器中断精确控制高低电平时序,以及如何利用键值映射完成前进、后退、转向等指令解析,对搭建类似无线遥控小车项目具有直接借鉴价值。

1. 红外遥控小车程序:一份能直接改用的51单片机遥控/避障参考

这份 PDF 里不是泛泛的教程,而是整套可以烧进单片机的红外遥控小车源码,包含 C 语言写的红外发送端、汇编写的解码驱动,以及 12864 液晶显示和自动寻迹避障逻辑。我拆完的感受是:它比网上那些只贴一个函数、缺东少西的“资料包”完整得多,按键扫描、定时中断、红外编码、电机方向控制、LCD 驱动全部串成了一条线。核心结论可能和第一印象相反:整个红外收发没有依赖任何现成的编码库,发送端全靠定时器 T0 溢出计数去拼脉冲宽度,协议是自定义的脉宽调制,不是标准 NEC,所以移植到 STM32、ARM 或其它单片机上时,反而更容易看懂每个延时到底是干什么的。适合刚做完单片机课程设计、毕业设计,或者想从 8051 代码里理解红外遥控原理的嵌入式硬件开发者。

2. 读懂红外遥控编码:发送端的定时中断与时序参数

这一章先把发送端拆开。整个发送逻辑的主线是:按键扫描得到键值,键值通过查表映射成功能码,功能码再进入sendirdata(),用定时器中断产生的count控制引脚下拉和拉高的时间。如果不理解这条主线,后面移植到别的平台时很容易把延时函数当成随便写的东西,改成固定的delay(20)就完事了,结果遥控距离和灵敏度都不对。

2.1 发送端整体结构:按键扫描、编码表与状态标志

先看最前面定义的数据结构,原文里的头文件和变量声明可以整理成这样:

#include <reg51.h> #define uchar unsigned char #define uint unsigned int sbit p3_0 = P3^0; // 红外发射引脚 sbit p2_1 = P2^1; // 预留的辅助控制引脚 sbit p1_0 = P1^0; // 按键矩阵第一行 sbit p1_1 = P1^1; // 按键矩阵第二行 sbit p1_2 = P1^2; // 按键矩阵第三行 sbit p1_3 = P1^3; // 按键矩阵第四行 bit flag_key = 0; // 按键按下标志 bit flag_set = 0; // 按键稳定标志 bit flag_press = 0; // 触发发送标志 uint keyvalue = 0x00; // 最终功能键值 uint keycount = 0; // 按键编号 uchar code keycode[4] = {0x7f, 0xbf, 0xdf, 0xef}; // 行扫描码 uchar code portvalue[12] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x00, 0x0b}; // 键值映射表 uchar code wy[8] = {0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80}; // 位取码表

这里几个表的作用要分清。keycode[4]是对 4 行按键矩阵分别拉低拉高的行码,0x7F 表示 P1.7 拉低,0xBF 表示 P1.6 拉低,依次类推。portvalue[12]把扫描到的物理按键位置映射成功能键值,比如前进、后退、左转、右转、停止和自动模式都对应其中一个值。wy[8]则是标准的位掩码表,从 0x01 到 0x80,专门给后面的红外数据逐位编码用的。

按键扫描函数是整个发送端的入口,看下面这段:

void key_scan(void) { uchar i; for (i = 0; i < 4; i++) { P1 = keycode[i]; // 一次只拉低一行 if (p1_3 == 0) { keycount = i * 3 + 0; flag_key = 1; break; } if (p1_2 == 0) { keycount = i * 3 + 1; flag_key = 1; break; } if (p1_1 == 0) { keycount = i * 3 + 2; flag_key = 1; break; } } if (flag_key == 1) flag_key = 0; // 清掉本次扫描标志 value1 = P1; deltime(); // 简单延时等待电平稳定 value2 = P1; if (value1 == value2) { // 两次读到的端口值一致 keyvalue = portvalue[keycount]; // 查表得到功能键值 flag_set = 1; flag_press = 1; // 告诉主循环去发送红外数据 } while (flag_set) { value2 = P1; if (value1 != value2) flag_set = 0; // 等待按键释放 } }

这段代码有个值得注意的设计:第一次读到 P1 的值后,调用deltime()短暂延时,再读一次,只有两次结果完全相同才认为按键确实按下。这比只读一次就执行任务的写法可靠得多。随后while(flag_set)循环会一直等到按键松开才退出,也就是说,一次完整的按键动作只触发一次flag_press = 1,避免按住不动时反复发红外码。

实际用的时候,deltime()在原文里是 20 次空循环,时间很短。这种消抖方式只能说是个基础框架,在机械开关上不一定能完美滤掉抖动。我一般会在真正的产品里把这段延时换成定时器产生的 10ms 时间片,或者在定时中断里维护一个按键扫描状态机。

2.2 38kHz 载波与 NEC 时序:endcount 20/60/180/320 的含义

发送函数sendirdata()是重头戏。它没有用硬件 PWM,也没有调制 38kHz 载波,而是直接拉低和拉高发射引脚,每个电平持续多少个endcount,全部靠 T0 定时器中断计数。

先看不带载波的起始部分:

void sendirdata(void) { uchar s = 0, datapd = 0; endcount = 320; p3_0 = 0; count = 0; do {} while (count < endcount); // 起始低电平 320 个时间单位 endcount = 180; count = 0; p3_0 = 1; do {} while (count < endcount); // 起始高电平 180 个时间单位 for (s = 0; s < 12; s++) { // 12 个固定脉冲 endcount = 20; count = 0; p3_0 = 0; do {} while (count < endcount); endcount = 20; count = 0; p3_0 = 1; do {} while (count < endcount); }

要理解这些数字,先得知道count每隔多久加一。在main()里:

TH0 = 0xFF; TL0 = 0xE4;

如果是 12MHz 晶振的标准 8051,机器周期是 1us,定时器从 0xFFE4 溢出到 0xFFFF 需要计数 12 次,也就是每 12us 产生一次中断,中断服务函数里count++。所以:

  • endcount = 320:低电平持续 320 × 12us = 3.84ms
  • endcount = 180:高电平持续 180 × 12us = 2.16ms
  • endcount = 20:脉冲宽度约 240us

这套时序和标准的 NEC 协议(9ms 引导低电平 + 4.5ms 高电平)并不一样,更像是一种简化过的脉宽调制:用固定宽度的脉冲做前导,后面再用两种不同长度的低电平加上固定的高电平来表示数据“0”和“1”。所以不能把它当成标准 NEC 去抓波形比对,否则容易误判代码有问题。

继续看数据位发送部分:

irdata = keyvalue; for (s = 0; s < 8; s++) { datapd = irdata & wy[s]; // 取当前位 if (datapd == 0) { endcount = 20; // 低电平 240us,表示 0 } else { endcount = 60; // 低电平 720us,表示 1 } count = 0; p3_0 = 0; do {} while (count < endcount); endcount = 20; count = 0; p3_0 = 1; do {} while (count < endcount); // 高电平固定 240us }

这段逻辑很直白:先取键值的最低位,如果这一位是 0,就输出 240us 低电平;如果是 1,就输出 720us 低电平,最后都收一个 240us 高电平。第二个循环又按同样的方式把 8 位数据再发送一遍,算是一种冗余发送,提高接收成功率。

这里要提醒一点:因为发送端没有额外的 38kHz 载波调制,如果接收端用的一体化红外接收头(内部带解调器),实际传输时需要在发射引脚上叠加 38kHz 方波,否则接收头可能解调不出高电平脉冲。解决方法有两种:一种是用定时器 PWM 生成 38kHz,在发送期间打开;另一种是干脆改用分离式红外管加运放,但这种方案在课程设计里很少见。

2.3 按键消抖与重复触发:value1、value2 相等判断

前面代码里已经出现value1和value2。核心思路是:在很短的时间内连续读两次 P1,如果两次读到的值相同,就认为按键状态稳定,再查表生成键值。这比用while(1)死等一个引脚电平要稳妥,至少能把按键按下瞬间的毛刺抖动过滤掉一部分。

但这里有一个容易踩的坑:deltime()的空循环次数太少,实际上可能没有真正延时 20ms。很多跟我一样的开发者拿到代码后,什么都不改,烧进开发板发现按下一次偶尔触发两次,就开始怀疑是红外编码问题,最后才想到是消抖没做够。常见做法是把这个延时改成基于定时器的非阻塞延时,或者在主循环里用标志位 + 计时变量组合。

还有那个while(flag_set)的释放等待,虽然能防止重复触发,但代价是按键未松开之前主循环会一直卡在这里。如果小车在执行动作的时候需要同时响应其它中断,这个位置就会变成性能瓶颈。更好的做法是把它改成一个“等待释放”状态,放进按键扫描状态机里,扫描函数立即返回,主循环才能继续干别的事。

3. 解码端与小车驱动:从外部中断 0 到电机控制

解码端全部用 8051 汇编写的,这是很多人第一次打开 PDF 会觉得头疼的部分。其实它的结构并不负责:外部中断 0 负责抓取接收头的下降沿,定时器中断负责在固定的时间间隔里采样引脚电平,采样结果存到内存区,最后由数据处理程序校验并转成小车动作。

3.1 汇编解码的数据接收流程:外部中断 0 启动定时器,定时中断读取电平

看中断入口:

ORG 0003H LJMP EXTER0INT ; 外部中断 0 入口 ORG 000BH LJMP TIMER0INT ; 定时器 0 中断入口 EXTER0INT: SETB TR0 ; 启动定时器 MOV 30H, #00H ; 宽度计数器清零 RETI

接收头的输出引脚接到外部中断 0 的输入端。平时没有红外信号时,输出保持高电平;一旦收到一串脉冲,第一个下降沿就会触发外部中断。中断服务函数做的第一件事就是启动定时器,同时把内存 30H 清零,准备开始记录时间。

接下来看定时器中断里的采样逻辑:

TIMER0INT: MOV TH0, #0FFH MOV TL0, #9CH ; 重装定时初值 INC 30H ; 每中断一次,宽度计数加 1 SETB P3.2 MOV C, P3.2 ; 读取红外接收引脚当前电平 JB PSW.5, DATARECEIVEPD

这里的定时初值是 0xFF9C。如果仍然按 12MHz 晶振理解,机器周期 1us,计数 100 次溢出,也就是每 100us 中断一次。这个周期和发送端 12us 的周期不一样,是很正常的事,因为解码端只需要对高低电平宽度做粗采样,100us 的采样间隔已经能分辨 240us 和 720us 两种脉冲。

INC 30H之后马上读P3.2,读到的电平存入进位位 C。接着判断一个数据接收标志 PSW.5。如果 PSW.5 还没置位,说明当前正在等待起始条件;如果 30H 超过一定阈值,说明已经看到了足够长的高电平或低电平,就可以认定这是一帧数据的启动瞬间,然后进入数据接收状态。

数据位判断的部分更直接:

DATARECEIVEPD: MOV A, 30H SUBB A, #10 ; 如果宽度小于 10 个采样周期 JC ORECEIVE ; 则认为是短脉冲 SETB C MOV A, 51H RRC A ; 当前位记 1,移入接收寄存器 MOV 51H, A LJMP WENDPD ORECEIVE: CLR C MOV A, 51H RRC A ; 当前位记 0 MOV 51H, A

这里用宽度是否大于 10 个采样周期来区分 0 和 1。10 个采样周期也就是 10 × 100us = 1ms,若采到的宽度低于 1ms 判为 0,高于 1ms 判为 1。这正好能区分发送端 240us 的短脉冲和 720us 的长脉冲,但边界余量不大,所以对晶振频率比较敏感。

3.2 数据校验与去重:FLAG_USER1、FLAG_USER2 和 52H~55H 缓存

解码端不是收到一个字节就立刻执行,而是连续接收多组数据,再做一个简单的校验和去重。代码里用 R2 = 4,R0 指向 52H,一组接一组往下存:

MOV R1, #08H MOV 51H, #00H MOV R0, #52H MOV R2, #04H

也就是说,接收缓冲区占用了 52H、53H、54H、55H 四个字节。每次收满 8 位就存入一个字节,直到 R2 减到 0,说明一组完整的数据已经收齐。之后DATACHULI会对这些字节做一致性检查:

DATACHULI: MOV A, 52H CLR C SUBB A, FLAG_USER1 JNZ ENDDATACHULI ; 第一组数据和预期标志不一致,丢弃 MOV A, 54H ANL A, 55H JNZ ENDDATACHULI ; 第二组数据校验不通过,丢弃 MOV A, 54H MOV SAVEDATA, A ; 校验通过,存入最终键值

这种做法的意义是把“收到数据”和“确认数据正确”分开,程序只会把连续收到且校验通过的结果用于控制小车,能在一定程度上防止红外干扰造成的误动作。实际调试时如果想临时看数据,可以把SAVEDATA里的值通过串口发到电脑上,比盯着 LCD 猜快得多。

3.3 方向控制与 LCD 显示:P1 口输出和 12864 半屏驱动

解码得到键值后,DATASHOW根据键值跳转到不同分支,每个分支不仅会改变 P1 口输出,还会调用LCD_DISP在液晶屏上显示当前动作。整理后的对应关系大致如下:

键值动作P1 输出LCD 显示
0x00进入自动寻迹避障0xC0寻迹壁障状态
0x02后退0xFA后转
0x04左转0xE5左转
0x05停止0xC5停转
0x06右转0xD5右转
0x08前进0xF5前转

这些 P1 值不是随便写的。以 0xF5 为例,二进制是 11110101,低四位直接给到电机驱动芯片的 IN1~IN4,不同组合对应电机正转、反转和刹车。如果以后换电机驱动板,需要逐位确认控制引脚顺序,不能直接照搬。

LCD 部分用的是 12864 双片选液晶,有 CS1 和 CS2 两个片选信号,分别控制左半屏和右半屏。代码里通过PAGES指定显示页,通过COLUMN指定列,再配合LCD_DISP里的查表逻辑把字模数据写到对应位置:

LCD_DISP: MOV R1, #02H ; 两行 MOV R0, #00H ; 字模表偏移 MOV NUM, #10H ; 每行 16 个点 DISP: MOV R4, NUM DISPWORD: MOV A, COLUMN CLR C SUBB A, #63 JNC YXPM ; 列号大于等于 64 则切换右半屏

这段代码看着复杂,本质就是在算当前要写入的是左半屏还是右半屏,然后再拼出 CS1、CS2、RS、RW、E 的时序。调试 LCD 的时候,我建议先写一个最简的“全屏清屏 + 显示一个固定字符”测试函数,确认左右半屏和忙检测都正常,再接入整套红外解码逻辑。否则一旦花屏,很难判断是解码问题还是液晶驱动问题。

3.4 自动寻迹避障模式:AOTO 状态机的切换逻辑

键值为 0x00 时会跳进AOTO子程序,进入自动寻迹避障模式。代码里先清屏,再显示“寻迹壁障”之类的字符,然后调用DLY做一个约 3 秒的延时,给操作者时间把小车站稳,之后再开始闭环检测:

AOTO1: MOV P1, #0F5H ; 默认直行 JNB PSW.5, AOTO2 ; 检测是否有新的红外遥控信号 LCALL CLEAR MOV P1, #0C5H ; 有遥控信号则退出自动模式 LCALL DLY1 LJMP MAIN AOTO2: JNB P1.6, ZZ ; 左边传感器触发,左转 JNB P1.7, YZ ; 右边传感器触发,右转 JNB P3.6, HT ; 后方障碍触发,后退 JNB P3.7, HTS ; 另一路障碍触发,原地调整 SJMP AOTO1

这个状态机和遥控模式是同一个主循环里的两个分支,差别只在于:遥控模式把SAVEDATA作为输入,自动模式把传感器引脚状态作为输入。P1.6 和 P1.7 是寻迹传感器信号,低电平有效;P3.6 和 P3.7 是避障传感器信号,低电平有效。运行中一旦检测到任何一路传感器拉低,就跳转到对应的转向子程序。

我在实际复现时发现,这里最容易忽略的是“转向保持时间”。如果程序在转向之后立刻回到AOTO1,车往往还没来得及转过足够角度,传感器又回到了无触发状态,视觉表现就是小车在线上疯狂抖动。解决思路是在转向子程序里加一点延时,让车轮多转 200~500ms,再回到主循环继续检测。

4. 把程序移植到 STM32/ARM:寄存器映射、定时器和 GPIO 的替换思路

PDF 里的代码是 8051 平台的,但很多人的目标是移植到 STM32 这类 ARM 单片机上。直接逐行翻译汇编肯定不现实,正确做法是先抓住三个关键点:GPIO 电平输出方式、定时中断周期、红外波形发送方式。这三个点能对上,剩下的逻辑都是纸面上的。

4.1 移植前先分清硬件差异:51 的 P1 口与 STM32 的 GPIO

8051 的 P1 口是准双向口,可以直接输出高低电平,也能读外部输入。STM32 的 GPIO 更像是可配置的复合接口,要先开启时钟,再设置模式、速度、上下拉。两者对照如下:

功能8051STM32
引脚输出P1 = 0xF5GPIOB->ODR = 0x00F5
引脚输入P1.6 低电平GPIO 配置成上拉输入,读取 IDR
输出模式准双向推挽输出或开漏输出
输入模式高阻输入上拉/下拉/浮空输入
时钟上电即工作必须先使能 GPIO 时钟

在 STM32 上输出电机控制信号,可以直接用寄存器操作,也可以用 HAL 库。给一个典型初始化片段:

void Motor_GPIO_Init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; HAL_GPIO_Init(GPIOB, &gpio); }

对应原来 P1 口的低四位输出,这里映射到 PB0~PB3。之后把 0xF5 这一类值通过HAL_GPIO_WritePin或直接改ODR寄存器输出即可。要注意的是 51 的准双向口输入能力默认存在,而 STM32 的引脚如果要用作输入,比如接红外接收头,就必须把引脚模式改成输入模式,否则读到的电平永远是无效的。

4.2 定时器中断替换:从 TH0/TL0 到 STM32 的 TIM

原代码里发送端用 TH0=0xFF、TL0=0xE4,在 12MHz 晶振下约 12us 产生一次中断。移植到 STM32 时不需要去算初值,直接配置定时器的预分频和自动重装载值就行。假设系统时钟 72MHz,想得到 12us 周期的中断:

void Timer_Init(void) { TIM_HandleTypeDef htim = {0}; __HAL_RCC_TIM2_CLK_ENABLE(); htim.Instance = TIM2; htim.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz htim.Init.Period = 12 - 1; // 1us 计数一次,12us 溢出 htim.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(&htim); HAL_TIM_Base_Start_IT(&htim); }

对应的中断服务函数里不要做太多事,只做“计数变量加一”或者“翻转 GPIO”这种轻量操作。原文中count++和endcount比较都是在主循环里完成的,所以中断服务函数只需要递增计数器,尽量保持和原 8051 逻辑一致,减少踩坑点。

如果追求更精确的发送时序,也可以用 STM32 的 PWM 输出脉冲,但 PWM 只能产生固定占空比波形,若要像原代码那样按位改变电平宽度,还是得在中断或定时器回调里切换比较值,复杂度反而更高。我的经验是:在项目初期先用定时中断 + 轮询计数的方式移植,跑通以后再优化成 PWM。

4.3 红外发送时序的位级实现:用阻塞延时还是硬件 PWM?

原代码发送一比特的逻辑可以提炼成两个基本动作:输出低电平一段时间,再输出高电平一段时间。在 STM32 上最简做法是实现一个微秒级延时函数,然后用它拼出完整波形:

void IR_Send_Bit(uint8_t bit) { if (bit == 0) { IR_LOW(); delay_us(240); IR_HIGH(); delay_us(240); } else { IR_LOW(); delay_us(720); IR_HIGH(); delay_us(240); } }

这段代码和原 8051 的逻辑是对应的。但要注意,STM32 主频高,软件延时不像 51 那样容易精确,建议优先用 DWT 或者定时器实现微秒延时,不要写三个嵌套 for 循环然后靠编译器猜时间。

如果你要接的是一体化红外接收头,还要在发送过程中把载波打开。一种常见做法是用另一个定时器产生 38kHz 的 PWM,在发送开始时使能 PWM 输出,发送结束关闭。用 HAL 库可以这样控制:

void IR_SetCarrier(uint8_t enable) { if (enable) { HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); } else { HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_1); } }

对于课程设计级别的小车,阻塞式延时通常够用;但如果后面要加超声波避障、蓝牙遥控、OLED 显示,多个任务同时跑,阻塞延时就会抢占 CPU,这时候就得考虑状态机加定时器了。

4.4 按键扫描与消抖:状态机替代延时查询

原文的按键扫描是“读两次端口 + while 等待释放”的阻塞方式,移植到 STM32 后,如果你还让按键扫描函数阻塞主循环,那小车转向的时候连电机 PWM 都没法更新。更好的方案是按键状态机。

下面是一段简洁的消抖状态机,把按键从按下到释放分成四个阶段:

#define KEY_IDLE 0 #define KEY_PRESS 1 #define KEY_CONFIRM 2 void Key_StateMachine(void) { static uint8_t state = KEY_IDLE; static uint32_t tick = 0; uint8_t now = (GPIOB->IDR & KEY_PIN) == 0; // 低电平有效 switch (state) { case KEY_IDLE: if (now) { state = KEY_PRESS; tick = 0; } break; case KEY_PRESS: if (!now) { state = KEY_IDLE; // 抖动,回到空闲 } else if (++tick >= 5) { // 连续 5 次扫描稳定 state = KEY_CONFIRM; Key_Execute(); // 只执行一次 } break; case KEY_CONFIRM: if (!now) state = KEY_IDLE; // 等待释放 break; default: state = KEY_IDLE; break; } }

在这个状态机里,Key_Execute()只会在按键从按下状态变成确认状态时执行一次,不会出现按下一次触发多次发送的问题。tick 的递增可以放在定时中断里,也可以放在主循环里每次 10ms 调用一次,效果是一样的。

5. 避坑手册:红外对不上、小车乱跑、LCD 花屏的五个排查记录

这一章直接写我在对照这份程序调小车时遇到过的坑,按“现象 → 原因 → 解决”的顺序说,能帮新手少走不少弯路。

5.1 发送端和接收端对不上:先确认载波和脉宽,再怀疑代码

现象:遥控器对着小车按键,LCD 没反应,接收端始终解不出有效数据。

原因:发送端代码只是把引脚拉低拉高,并没有输出 38kHz 载波。如果接收端使用的是 VS1838B 这类一体化接收头,它内部只对 38kHz 调制的红外信号敏感,直接给一个持续 240us 或 720us 的低电平,接收头不一定能正确翻转输出。另外,发射管的限流电阻如果选太大,红外管发光弱,接收距离会骤减。

解决:先拿逻辑分析仪或示波器看发射引脚波形,确认是否有符合预期的脉冲序列。如果确实没有载波,在发送期间叠加 38kHz PWM。如果波形正常但距离只有几厘米,检查发射管限流电阻,常见 5V 下用 100~220Ω 串联电阻,不要用 1kΩ 以上的电阻。

5.2 定时器初值算错:0xFFE4 和 0xFF9C 的周期差

现象:发送端看起来正常,但接收端偶尔收到乱码,尤其换了晶振后问题更明显。

原因:发送端定时器初值是 0xFFE4,接收端用的是 0xFF9C,两者的溢出周期完全不一样。很多初学者把两个初值当成同一个参数去“统一”,结果发送端时序已经变了,接收端还按原来的采样宽度判断,自然对不上。

解决:先算出每个平台的实际溢出周期。以 12MHz 晶振为例,发送端溢出周期是 12us,接收端是 100us。如果你换成了 11.0592MHz 晶振,机器周期变成约 1.085us,那么同样的 TH0、TL0 对应的实际时间都不一样,必须重新算。不能只是把 hex 文件烧进去就完事。

5.3 按键按下一次却连续执行:释放判断被跳过

现象:按下遥控器的前进键,小车连续前进,按一次像按了好几次。

原因:原代码用while(flag_set)等待按键释放,但如果按键的抖动让value1和value2在某一瞬间不相同,flag_set被提前清掉,按键还未真正释放,主循环又进入下一次扫描,导致重复触发。另一个原因是deltime()太短,没有滤掉机械抖动。

解决:把消抖时间加长到 10ms 以上,并加上“按键释放确认”状态。不要让扫描函数卡在 while 循环里等待,改成状态机,只有释放完全确认后才允许下一次按下触发。

5.4 LCD 花屏和显示不全:12864 半屏片选时序不完整

现象:LCD 左半边显示正常,右半边乱码,或者某个汉字显示到错误位置。

原因:12864 的 CS1 和 CS2 分别控制左右半屏,每条命令和数据的写入顺序必须严格。原文中的WCL0、WCR0是左右半屏写命令函数,它们的本质都是“先检查忙标志,再拉高 E 脉冲写入数据”。如果用的液晶模块和代码预期的型号不一致,忙检测时间和 E 脉冲宽度都会有差异,就会花屏。

解决:先单独测试 LCD,给固定字符地址写一个字节,确认左右半屏都正常,再接入红外解码。如果忙检测不稳定,可以在读取忙标志前把 P2 口先全部置 1,读完之后再恢复输出。还有一种省事办法:把忙检测删掉,每次写入前固定延时 20us,牺牲一点速度换稳定性,对小车这种低频显示场景完全够用。

5.5 汇编和 C 混用 / 兼容性:换个增强型 51 后程序表现不同

现象:代码在普通 8051 上正常,烧到某国产增强型 51 上以后,LCD 闪烁、电机动作混乱,但程序明明没改。

原因:增强型 51 的时钟系统、看门狗、复位电路和基础 8051 不完全一样。原程序没有关闭看门狗,芯片上电后看门狗周期性地复位程序,表现就是功能一会正常一会又回到初始状态。还有一些增强型单片机上电后默认启动了定时器 2 或内部 RC 振荡器,导致定时器 0 的实际溢出时间和计算值不符。

解决:在 main 函数最开始关掉看门狗,并把系统时钟配置成外部晶振或明确的内部时钟频率。汇编部分的 P3.2、P1.6 这些绝对地址不变,但如果你在 Keil 里把存储器模式设置成大模式,也可能出现数据指针问题。课程设计场景建议保持 Small 模式,减少 SFR 访问冲突。

6. 验证与进阶:先抓波形,再把单向控制改成双电机 PWM

拿到这份 PDF 后,我建议先不要急着改功能,按下面顺序做一次验证,能省掉后面大半的排查时间。

第一步核对接线:发射引脚 P3.0 通过限流电阻接红外发射管,接收头输出接外部中断 0 对应引脚,P1 口接电机驱动输入,LCD 按 12864 的 CS1/CS2、RS、RW、E 依次接好。烧录后先按遥控器,看 LCD 是否显示对应的“前转”“左转”等状态,再测量电机驱动输入引脚的电平,确认 P1 口输出和表格里的一致。

第二步用逻辑分析仪抓发送端波形:把探头夹在发送引脚和地之间,按下按键,观察一串脉冲里低电平宽度。如果看到 240us 和 720us 两种宽度交替,说明发送端定时器初值没问题。再用另一个探头抓接收头输出,确认解码端采集到的波形翻转点和发送端一致。这一步能直接暴露“发送正常但接收不到”的问题。

第三步验证解码结果:可以在汇编数据处理处加一个临时的串口输出,把SAVEDATA通过串口发到电脑,串口助手显示 0x04、0x05、0x06 这些值。比起反复烧录、用 LCD 观察,串口调试快得多。示波器看完波形以后,接下来想升级成双电机差速小车,只需要保留 P1 口的方向输出,把速度控制交给两个 PWM 通道。

// STM32 上设置左轮和右轮 PWM 占空比 void Motor_SetSpeed(uint16_t left_duty, uint16_t right_duty) { __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, left_duty); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, right_duty); }

转弯时不要再简单地给一个固定方向电平,而是输出差速:左转时左轮降速或停止,右轮保持中速;右转时右轮降速。这样车的轨迹比原地转向平滑很多,也更好控制。把自动寻迹和遥控模式合并成同一个状态机,用键值切换模式,遥控模式下保留原来的方向控制,自动模式下用传感器值决定差速方向,就能做出一个更完整的小车控制程序。

这里说一个我自己的习惯:拿到任何一份单片机课程设计源码,我都会先把发送端和解码端的定时器初值抄下来,算清楚各自的溢出周期,再动手改代码。以前调试一套红外小车,就是因为没核对晶振频率,把发送端时序当成标准 NEC 去比对,结果折腾了一整天才发现只是初值算错。从那以后,我每次调这类带红外收发的小车,都强制先抓一条波形再谈下一步。希望帮到你。

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

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

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

立即咨询