在大学里学过“微机原理与接口技术”的同学,十有八九会碰到一个经典课设:模拟十字路口交通信号灯。这题目看着简单,实际做起来能把并行接口、定时器/计数器、中断系统、状态机逻辑全部串起来,做完一遍,汇编指令和I/O读写的理解基本就通了。这篇文章把我从方案选型、硬件连线到程序调试的完整过程写出来,尤其是几个容易踩的坑和调试思路,给正在为课设发愁的同学做个参考。
1. 需求拆解与方案选型
1.1 信号灯的基本功能需求
十字路口交通信号灯,最核心的逻辑就是“互斥放行”。四个方向两两一组,东西方向绿灯放行时,南北方向必须红灯禁行;等东西方向的绿转黄,南北方向的红灯继续保持;黄灯结束后,东西方向才转红,南北方向再放行。这样一个周期分成四个相位:
- 南北方向绿灯,东西方向红灯,持续25秒。
- 南北方向黄灯,东西方向红灯,持续5秒。
- 东西方向绿灯,南北方向红灯,持续25秒。
- 东西方向黄灯,南北方向红灯,持续5秒。
四个状态循环一次正好60秒。这个时序看起来不复杂,但对接口编程来说,要同时处理“灯色输出”和“秒计时”两件事:灯色是并行口的输出问题,计时是定时器/计数器的问题。如果还要加上倒计时数码管显示、夜间黄闪、紧急车辆优先等功能,就额外需要动态扫描、按键输入和中断嵌套处理,难度会明显上台阶。作为课程设计,我建议先保证基础四相位稳定运行,再考虑扩展功能。
1.2 为什么选择8086+8255A+8253,而不是直接上单片机
很多同学会问,用STM32写个GPIO翻转不是更容易吗?确实容易,但“微机原理”这门课的重点是接口技术。51单片机、STM32这类微控制器把GPIO、定时器、中断控制器全部集成在芯片内部,程序里只需要配置寄存器,很难看到“地址译码”“片选信号”“控制字”“I/O读写时序”这些底层概念。而8086配合8255A并行接口芯片、8253定时器/计数器芯片、74LS138译码器,是一套经典教学组合,能够把CPU和外设之间的交互完整呈现出来。
我最终选择的是8086最小模式,外扩8255A作为灯色控制口,8253作为秒脉冲发生器,74LS138做地址译码,用Proteus仿真完成整体调试验证。这套方案的好处是:
- 硬件结构清晰,每个芯片负责一件事,方便写实验报告。
- 汇编程序规模适中,能覆盖课程要求的知识点。
- Proteus仿真不需要买实体芯片,成本低,出问题也好排查。
1.3 与基于ARM的移植思路的关系
“基于ARM的微机原理与接口技术”现在也经常被提起。ARM方案本质上是把8086里的8255、8253、8259换成了STM32的GPIO、TIM、NVIC,但状态机逻辑是一模一样的。我个人的建议是:如果还在上微机原理课,先用8086把接口时序搞明白;做完以后再去思考ARM移植,你会发现很多概念是相通的。这篇文章最后我专门用一节聊ARM移植思路,能帮大家把知识串起来。
2. 硬件连接与初始化配置
2.1 器件清单与Proteus环境准备
我的仿真环境是Proteus 8.x,汇编程序用MASM编译生成obj文件,然后加载到Proteus里的8086模型中运行。硬件器件清单如下:
- 8086 CPU,最小模式,时钟信号由Proteus里的时钟源提供。
- 8255A可编程并行接口,负责向LED输出灯色控制信号。
- 8253/8254定时器计数器,在本设计中使用计数器0,产生周期性脉冲。
- 74LS138三线八线译码器,用于产生8255A和8253的片选信号。
- 6个LED模拟红黄绿三色信号灯,分为东西、南北两组。
- 1个逻辑探针或示波器,用于观察定时器输出波形。
如果你的实验板是真实硬件而不是仿真,还要准备面包板、跳线、580欧姆限流电阻等。Proteus仿真里LED可以直接接8255的输出引脚,但实际板子必须加限流电阻,否则会烧毁LED或损坏8255的引脚。
2.2 地址译码与端口地址分配
8086访问外设时,需要通过对I/O端口地址进行译码来选中特定的接口芯片。这里用74LS138把CPU送出的地址信号译成片选信号。以我用的端口地址为例:
| 芯片 | 端口 | 地址 |
|---|---|---|
| 8255A | 端口A | 0300H |
| 8255A | 端口B | 0301H |
| 8255A | 端口C | 0302H |
| 8255A | 控制字寄存器 | 0303H |
| 8253 | 计数器0 | 0304H |
| 8253 | 计数器1 | 0305H |
| 8253 | 计数器2 | 0306H |
| 8253 | 控制字寄存器 | 0307H |
注意,这个地址分配依赖于74LS138的接线方式。不同的译码器接法会得到不同的端口地址,所以在仿真里写程序前,建议先用探针或内存观察窗口确认你的端口地址。通用的做法是:138的输入端接CPU的A3、A4、A5,使能端接高地址线,输出端Y0接8255A的CS,Y1接8253的CS;8255A和8253的A1、A0则直接接CPU的A1、A0,用于选择内部端口。只要能保证几个端口地址互不重叠,就算接线合格。
2.3 8255A控制字与8253方式字初始化
8255A工作在方式0基础输出模式,A口、B口、C口都设为输出。控制字需要写入控制字寄存器,格式是10000000B,也就是80H。
; 8255A初始化 MOV DX, 0303H ; 8255A控制字寄存器端口 MOV AL, 80H ; 方式0,A/B/C口全部输出 OUT DX, ALA口负责南北方向三色灯,B口负责东西方向三色灯,C口留作扩展用,比如接倒计时数码管或者按键。8253这边,我让计数器0工作在方式2,也就是分频器模式,输入时钟频率设为1MHz,计数初值设为50000,这样OUT0引脚输出的信号频率就是20Hz,周期50ms。
计数初值的计算公式是:
- 输出信号频率 = 输入时钟频率 / 计数初值
- 1000000 / 50000 = 20Hz
- 20Hz信号的周期 = 1 / 20 = 0.05秒 = 50ms
8253是16位计数器,最大能装65535,50000没有超限,用二进制计数即可。初始化代码如下:
; 8253初始化,计数器0,方式2,二进制计数,先写低字节再写高字节 MOV DX, 0307H ; 8253控制字寄存器 MOV AL, 00110100B ; 计数器0,先低后高,方式2,二进制 OUT DX, AL MOV AX, 50000 ; 计数初值,十进制50000 = 0xC350 MOV DX, 0304H ; 计数器0端口 OUT DX, AL ; 先写低字节 0x50 MOV AL, AH OUT DX, AL ; 再写高字节 0xC3如果不想做中断,也可以把OUT0引脚接到一个逻辑探头,通过查询电平变化来计时。不过课程设计里最好把8253的输出接到8259A中断控制器,用每50ms一次的中断来维护软件时钟,这样更能体现“定时器+中断”的完整流程。8259A的初始化比较简单,设定中断类型码和优先级后,在中断服务程序结束时发送EOI命令(20H)即可。
3. 交通灯状态机与核心算法设计
3.1 状态定义与输出码表
交通灯程序的核心是一个四状态的状态机。每个状态要同时决定南北方向和东西方向的灯色,还要知道自己应该持续多少秒。我用了数据段里的一张状态表来管理这些信息。
状态表每个条目包含三个字节:A口输出值、B口输出值、持续时间。假设LED为共阴接法,输出高电平点亮,那么:
- 南北红灯、东西绿灯:A口输出00000100B(04H),B口输出00000001B(01H)。
- 南北红灯、东西黄灯:A口输出04H,B口输出00000010B(02H)。
- 南北绿灯、东西红灯:A口输出01H,B口输出04H。
- 南北黄灯、东西红灯:A口输出02H,B口输出04H。
把这四个状态定义成表格:
STATE_TABLE: DB 04H, 01H, 25 ; 状态0:南北红,东西绿,持续25秒 DB 04H, 02H, 05 ; 状态1:南北红,东西黄,持续5秒 DB 01H, 04H, 25 ; 状态2:南北绿,东西红,持续25秒 DB 02H, 04H, 05 ; 状态3:南北黄,东西红,持续5秒这里把南北方向作为A口,东西方向作为B口。程序运行时只需要查表输出即可,完全不用写一堆if-else判断灯色,逻辑非常清晰。
3.2 状态切换与秒计时逻辑
程序里用两个变量维护时间:
TIMER_CNT:每50ms中断累加一次,加到20表示满1秒。SEC_CNT:当前状态已经持续的秒数。
每次满1秒时,把SEC_CNT加1,然后与状态表中的持续时间比较。如果SEC_CNT大于等于持续时间,就把当前状态号加1,并对4取模,进入下一个相位,同时把SEC_CNT清零。
主循环不需要做复杂计算,只需要查询秒标志位是否置位,如果置位就调用状态更新子程序。代码结构如下:
MAIN_LOOP: CMP BYTE PTR [FLAG_1S], 1 JNE MAIN_LOOP MOV BYTE PTR [FLAG_1S], 0 CALL UPDATE_STATE JMP MAIN_LOOP这种“中断产生标志,主循环处理业务”的写法,在嵌入式开发里非常常见,好处是中断服务程序尽量短,不容易出错,主循环逻辑也容易调试。
3.3 输出子程序与互斥保护
状态更新其实就两步:查表取出A口和B口的输出值,然后分别写到8255A的A口和B口。但这里有一个容易被忽略的细节:切换瞬间必须保证不会出现“南北绿灯还没灭,东西绿灯已经亮”的情况。实际交通灯不会这样,因为状态表中每个状态都同时包含两个方向的灯色,但程序执行时如果先写A口、再写B口,可能在两次输出之间出现短暂的非法状态。
解决办法是在切换前先把所有灯熄灭,再输出新的状态。我在OUTPUT_LIGHTS子程序里这样处理:
; 输出灯色,防止红绿冲突 OUTPUT_LIGHTS: MOV DX, 0300H ; 8255A端口A MOV AL, 00H ; 先灭全部南北灯 OUT DX, AL MOV DX, 0301H ; 8255A端口B MOV AL, 00H ; 先灭全部东西灯 OUT DX, AL MOV BX, OFFSET STATE_TABLE MOV AL, STATE MOV AH, 3 MUL AH ; 每个状态占3字节,计算偏移 ADD BX, AX MOV AL, [BX] ; 取A口输出值 MOV DX, 0300H OUT DX, AL MOV AL, [BX+1] ; 取B口输出值 MOV DX, 0301H OUT DX, AL RET尽管两次清灯会让LED瞬间熄灭一下,肉眼看不出来,但能从根本上避免红绿同亮的危险状态。课程设计如果要求严格,可以在状态表中直接设计互斥编码,确保两个方向不会同时出现绿灯。
4. 汇编程序实现与关键代码解析
4.1 程序整体框架
完整的汇编程序由数据段、代码段、中断服务程序三大部分组成。数据段里放状态表、状态号、秒计数变量、定时中断计数变量;代码段里做8255A和8253的初始化,然后进入主循环;中断服务程序负责每50ms累加一次时间,满1秒时把FLAG_1S置1。
我建议把整个程序分成几个子程序:INIT_8255、INIT_8253、UPDATE_STATE、OUTPUT_LIGHTS、TIMER_ISR。这样每个功能独立,调试时能单独验证。
4.2 初始化部分的参考代码
下面这段是把8255A和8253初始化封装成子程序的写法,可以直接用在MASM51、MASM6.11等汇编环境中编译:
INIT_8255 PROC MOV DX, 0303H MOV AL, 80H OUT DX, AL RET INIT_8255 ENDP INIT_8253 PROC MOV DX, 0307H MOV AL, 00110100B OUT DX, AL MOV DX, 0304H MOV AX, 50000 OUT DX, AL MOV AL, AH OUT DX, AL RET INIT_8253 ENDP如果8253的输入时钟不是1MHz,而是其他频率,需要根据实际时钟重新计算计数初值。比如输入时钟是2MHz,想得到50ms周期,计数初值就是100000,这已经超过16位计数器的最大范围,此时需要用两个计数器级联,或者把时钟频率降低。这一点在课程报告中一定要写清楚。
4.3 中断服务程序设计
8259A初始化后,如果8253计数器0的OUT0信号接到IRQ0,那么每次定时时间到都会触发中断。中断服务程序里只做三件事:判断是否满1秒、更新标志、发送EOI。
TIMER_ISR PROC FAR PUSH AX PUSH DS MOV AX, DATA MOV DS, AX INC BYTE PTR [TIMER_CNT] CMP BYTE PTR [TIMER_CNT], 20 JNE TIMER_ISR_END MOV BYTE PTR [TIMER_CNT], 0 MOV BYTE PTR [FLAG_1S], 1 TIMER_ISR_END: MOV AL, 20H ; 发送EOI命令给8259A OUT 20H, AL POP DS POP AX IRET TIMER_ISR ENDP注意中断服务程序结尾的IRET不能写成RET,否则返回地址会出错。中断服务程序里尽量减少PUSH和POP的数量,避免堆栈不平衡。
4.4 软件延时的替代写法
如果你暂时没有搞定8253中断,也可以用软件延时来模拟1秒。原理是让CPU执行空循环若干次,直到时间足够。比如:
DELAY_1S PROC MOV CX, 0FFFFH DELAY_LOOP: LOOP DELAY_LOOP RET DELAY_1S ENDP但这种写法很有争议:不同CPU主频下延时时间完全不同,仿真速度也会影响实际效果。只适合快速验证灯色输出,不适合作为最终提交方案。课程设计的重点在于接口技术,建议把8253的功能真正用起来,软件延时可以作为备选写在报告的“方案对比”部分。
5. 调试过程与常见问题排查
5.1 常见问题速查表
我在调试过程中遇到过不少“脑袋冒烟”的场景,总结成一张速查表,大家可以直接对照排查:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| LED全不亮 | 8255A初始化方向设成输入了 | 检查控制字是否为80H或81H |
| LED全不亮 | 8255A片选地址不对 | 用逻辑探针检查CS引脚是否有低电平脉冲 |
| LED全不亮 | LED接错共阴共阳极性 | 共阳极LED要输出低电平点亮,共阴极则相反 |
| 只有一组灯亮 | A口或B口地址写错 | 核对端口地址:0300H是A口,0301H是B口 |
| 黄灯不亮 | 状态表里B口或A口位定义错误 | 重新确认位2对应黄灯,位1对应绿灯,位0对应红灯 |
| 红绿同时亮 | 状态切换时没有先清空输出 | 在输出新状态前先向两个端口写00H |
| 切换时间特别快或特别慢 | 8253计数初值错 | 重新计算初值并检查输入时钟频率 |
| 程序卡在中断里 | 没有发EOI给8259A | 中断服务程序末尾必须MOV AL, 20H+OUT 20H, AL |
| 仿真时LED闪烁明显 | 主循环刷新太频繁 | 调整延时或降低扫描频率 |
5.2 自顶向下的调试方法
我调试这套程序时,没有一上来就烧录完整代码,而是分了三步走。
第一步,单独测试8255A。写一个最简单的程序,往A口循环输出01H、02H、04H,用LED观察是否正常点亮。如果这一步都不对,多半是端口地址或控制字的问题,不急着往下调试。
第二步,单独测试8253。把8253的OUT0引脚接到逻辑分析仪或示波器,观察输出信号周期是否为50ms。如果周期不对,检查计数初值和控制字。8253的GATE引脚一定要接高电平,否则计数器不会工作,这是很多人忽略的点。
第三步,把8255A和8253结合起来,先把中断服务程序里的FLAG_1S置位逻辑测试好,再让主循环查状态表。这样每一小块都验证过,最后组合时只需要处理状态切换逻辑,问题范围就能缩得很小。
5.3 一些独家避坑细节
- 不要在主循环和中断服务程序里同时写
SEC_CNT,否则会出现变量竞争问题。正确做法是中断里只置标志,主循环里改状态。 - 用MASM编译汇编程序时,数据段定义的变量名字不要和指令助记符重复,比如
CNT、LOOP这类容易冲突。 - 在Proteus仿真里,8086的RD和WR信号要注意连接,否则8255A和8253虽然被选中,但读写控制信号不对导致数据传不进去。
- 如果使用了8253计数器0,却没有把OUT0引脚连到8259A的IRQ0,那么初始化和中断向量表设置得再正确也不会触发中断。
6. 进阶延伸:从8086到基于ARM的移植思路
6.1 ARM平台与8086方案的映射关系
很多课程升级成了“基于ARM的微机原理与接口技术”之后,核心知识点并没有变,只是换了载体。以STM32为例,原来的8255A对应STM32的GPIO端口,8253对应STM32的TIM定时器,8259A对应STM32的NVIC中断控制器。你在8086里写的状态表、状态切换算法、变量标志逻辑,在STM32上几乎可以原样搬过去,区别只是把OUT DX, AL换成GPIO_WriteBit或寄存器操作。
这种映射关系想清楚之后,再回头看微机原理的东西,会发现这些接口芯片并没有过时,只是被集成到了芯片内部。地址总线、数据总线、控制总线的外置变成了寄存器配置,但底层的时序逻辑仍然存在。
6.2 一个最小STM32实现思路
在STM32上复现交通灯程序,推荐用标准外设库或HAL库,核心代码思路如下:
// 伪代码,示意用 // 状态表 typedef struct { uint8_t ns_light; // 南北方向灯色 uint8_t ew_light; // 东西方向灯色 uint8_t duration; // 持续秒数 } State; State state_table[4] = { {RED, GREEN, 25}, {RED, YELLOW, 5}, {GREEN, RED, 25}, {YELLOW, RED, 5}, }; volatile uint8_t timer_cnt = 0; volatile uint8_t flag_1s = 0; // TIM3中断服务,设为50ms一次 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); timer_cnt++; if (timer_cnt >= 20) { timer_cnt = 0; flag_1s = 1; } } } int main(void) { GPIO_Config(); TIM3_Config(); uint8_t state = 0; uint8_t sec = 0; while (1) { if (flag_1s) { flag_1s = 0; sec++; if (sec >= state_table[state].duration) { sec = 0; state = (state + 1) & 0x03; GPIO_SetLight(state_table[state].ns_light, state_table[state].ew_light); } } } }这个程序比汇编版本更容易读,但如果你只会写ARM版本,不理解8255、8253这些接口芯片,做传统微机原理课设依然会吃力。建议把两个版本都跑一遍,对比它们对状态机的处理方式。
6.3 更贴近真实交通灯的扩展方向
做完基础功能后,可以尝试加扩展功能,这也是课程报告加分的关键:
- 倒计时显示:用两个数码管分别显示南北和东西的剩余秒数,C口输出段码,通过动态扫描刷新。
- 夜间模式:深夜时四个方向全部黄灯闪烁,表示车辆减速观察通过。实现方法是增加一个状态,黄灯交替闪烁。
- 紧急模式:按下按键后,所有方向红灯,保持5秒,模拟救护车通过。
- 车辆检测:加上红外传感器或压力传感器,根据车流量动态调整绿灯时间,这就是真实智能交通控制系统的雏形。
这些扩展功能看起来复杂,其实核心还是状态机,只是把状态表加长,输入条件增加。先把握好基础设计,再往上面叠功能,会让你的课程设计层次明显高于平均水平。
最后再分享一个小技巧:这类型课程设计,动手写代码前一定先画一张状态图和一张时序图。状态图保证逻辑不出错,时序图保证和硬件配合时不打架。我当时就是因为没画图直接写汇编,结果反复调了三天才意识到是8253的GATE没接高电平。把这张图画出来放在报告里,老师也会觉得你思路清晰,比自己闷头调要高效得多。