简介:《TDX-PITE-80X86微机接口应用技术实验教程》是一份面向微机原理与接口技术课程学习者及实验指导教师的实践型PDF文档,围绕80X86微处理器与外部硬件设备的交互展开,适合课程实验、课堂演示与课后复盘使用。压缩包内共1个PDF文件,约909KB,篇幅精炼,便于在电脑或移动端直接查阅。教程先介绍FPGA单元资源,说明50MHz全局时钟、JTAG下载口及扩展插座引脚分配,并梳理8259、8255、8254、8251、8237等接口芯片与FPGA引脚的对应关系;随后给出键盘扫描及显示、电子发声、点阵LED显示、双机通讯、步进电机、温度闭环控制、直流电机闭环调速等实验项目,涵盖接口电路、字符显示驱动、音频信号合成、串并行通信、脉冲与方向控制、温度实时监控和反馈调速等知识点。文档还附有唐都公司网址、技术支持邮箱与QQ等支持途径,便于遇到实验问题时追溯资料。已有664人学习,可作为接口技术实验课配套参考,帮助读者把理论原理落到具体硬件连接与控制程序思路上。
1. 平台选型:为什么这门课不能只靠仿真软件交差
某同学做微机接口课程设计,仿真软件里 8255 读写一切正常,真机上却数码管全黑、按键毫无响应,查了两天发现是片选线接到了另一个 IOY 输出上。这类问题在纯软件环境里永远碰不到,恰恰是答辩现场最容易被追问的地方。这份 80X86 微机接口应用技术实验教程作为一份完整的课程资源,价值就在这里:它把 FPGA 引脚号、单元片选、接口芯片端口三者固定在同一张表里,按表接线、按端口地址写程序,八个实验能从键盘扫描一路做到温度与转速闭环。适合正在做课程设计、毕业设计、需要一个可复现硬件验证平台的人,也适合手上有实验箱、代码写完了但一直跑不通的从业者。
2. FPGA 单元资源盘点:接线前必须先锁定的三张对照表
微机接口实验最容易出问题的地方,通常不出在代码本身,更常见的是“我以为这根线接的是那个信号”。实验平台本质上是把 CPU 总线、五六个接口芯片和一堆外设单元堆在一块板子上,中间靠 FPGA 做地址译码和片选分发。接线之前,先把物理位置、引脚映射、地址分配这三件事固定下来,后面写程序才不至于边猜边改。
2.1 时钟、下载口与扩展插座的物理位置
板载有一个 50MHz 有源晶振,输出直接连到芯片的 PIN_25 脚,也就是 CLK3,这条线通常被当作全局时钟使用,定时器分频、点阵扫描基准都从它来。JTAG 下载口在 8251 单元和 8237 单元之间,位置比较隐蔽,第一次找的时候容易在板子边上来回摸。扩展插座的引脚分配图(JP40、JP50 两个排针)是整个实验的“地图”,PIN_83 到 PIN_144 的编号看着杂乱,实际是按接口芯片的端口顺序排布的。
我一般会做的第一件事,是把引脚分配图打印出来贴在工位挡板上,接线时用荧光笔把已经用过的引脚划掉。听起来很土,但一个实验做下来要插拔几十根线,靠记忆迟早出错。尤其是 8255 的 PA、PB、PC 三组端口和 8254 的三个计数器,引脚编号完全不连续,看错一位就要查半天。
2.2 五个接口单元的片选与总线走向
出厂时 FPGA 内部已经固化好接口芯片逻辑,用户拿到的是一块“已经变成 8255/8254/8251/8259/8237”的板子。这五颗逻辑芯片的片选信号各自独立引出:8255_cs 在 PIN_23,8254_cs 在 PIN_30,8251_cs 在 PIN_24,8259_cs 在 PIN_28,8237_cs 在 PIN_86。片选之外,读写信号 IOW(PIN_73)、IOR(PIN_71)、复位 RST#(PIN_84)是五个单元共用的。
地址线 A0 到 A15、数据线 D0 到 D7 统一接到各单元的地址/数据总线端,具体哪几根地址线参与片内译码,取决于单元内部有没有多个可寻址寄存器。8255 需要 A0、A1 两根区分四个端口,8254 同样需要两根,8251 只需要一根区分数据口和命令/状态口,8259 也只要一根。这个差异直接决定了后面写程序时端口偏移量的算法,接错一根 A 线,程序就会去读写一个不存在的寄存器,现象是“写了没反应、读了全是 FF”。
提示:片选信号接到哪个 IOY 输出,直接决定这个单元的基地址。动手前先看单元面板上的丝印标注,不要凭经验假定所有实验箱的地址分配都一样。
2.3 一张接线前必须填完的地址核对表
下面这张表是这类实验平台最常见的一种地址分配方案,实际数值要按自己板子上的跳线或丝印核对,但结构是通用的:
| 单元 | 片选信号 | 示例基地址 | 端口偏移 | 关键引脚 |
|---|---|---|---|---|
| 8255 | 8255_cs | 0600H | +0=PA,+1=PB,+2=PC,+3=控制口 | A0、A1 |
| 8254 | 8254_cs | 0640H | +0=计数器0,+1=计数器1,+2=计数器2,+3=控制口 | A0、A1,CLK0 接 1MHz |
| 8251 | 8251_cs | 0680H | +0=数据口,+1=命令/状态口 | A0,CLK 接 1.8432MHz |
| 8259 | 8259_cs | 06C0H | +0=偶地址,+1=奇地址 | A0,INT 接 CPU |
| 8237 | 8237_cs | 0700H | 16 个寄存器依次排列 | A0-A3,HLDA/HOLD |
表里“示例基地址”这一列,新手最容易当成固定值直接写进代码。真实情况是这个基地址由片选译码电路决定,有的平台用 0600H 一档,有的用 0280H,还有的通过跳线帽切换。上机第一步永远是核对,不是编程。
2.4 用一个最小自检程序先把地址钉死
与其写完几百行实验代码再回头排查地址,不如先用二十行程序验证基地址。8255 是最合适的探针,因为它有四个可寻址端口,写进去能立刻读回来:
#include <dos.h> #include <conio.h> /* 8255 端口自检:确认基地址与片选接线是否匹配 * 返回 1 表示地址正确,0 表示读回不一致 */ int check_8255(unsigned int base) { unsigned char v; outportb(base + 3, 0x80); /* 控制字:A/B/C 三口全部设为输出 */ outportb(base + 1, 0x55); /* 向 B 口写 0101 0101 */ v = inportb(base + 1); /* 输出方式下读回的是输出锁存器内容 */ if (v != 0x55) return 0; outportb(base + 1, 0xAA); /* 再写 1010 1010,排除固定电平干扰 */ v = inportb(base + 1); return (v == 0xAA) ? 1 : 0; } void main(void) { if (check_8255(0x0600)) printf("8255 base 0x0600 OK\n"); else printf("8255 base wrong, check IOY wiring\n"); }这段代码的关键在于“写两个互补的图样再读回”。只写 0x55 读回 0x55 并不能说明问题——如果端口悬空、有上拉电阻,读回 0xFF 或固定值也可能碰巧对上。写 0x55 和 0xAA 两次,如果两次都对,基本可以排除悬空和固定电平。参数 base 换成自己板子上的实际值再试一次,两个地址里只有一个能通过,那个就是真地址。
这套自检思路后面能反复用:点阵实验前先用它验证 8255 输出口,双机通讯前先用它验证两个单元不冲突。血泪经验是,先花十分钟确认地址,能省掉后面两小时的玄学排查。
3. 并行 I/O 与显示三件套:4×4 键盘、四位数码管、16×16 点阵
这一组实验的共同点是都用 8255 做并行口,但难点各不相同:键盘难在扫描时序和消抖,数码管难在动态扫描的刷新率,点阵难在字模的位序和块顺序。三个实验串起来做,正好把“输入—缓冲—输出”这条链路走通一遍。
3.1 键盘与数码管的端口分工
4×4 键盘有 4 根行线 X1-X4 和 4 根列线 Y1-Y4,行线通过 1K 上拉电阻接到 VCC,列线由 8255 输出扫描电平。数码管这边,a 到 g 加 dp 共 8 根段码线,四位数字的公共端 com1-com4 是位选线。8+4=12 根线,正好塞进 8255 的两个端口加半个端口。
我采用的分配是:PA0-PA3 接行线做输入,PC0-PC3 接列线做输出,PB0-PB7 接段码做输出,PC4-PC7 接位选做输出。这样 8255 的工作方式定为方式 0,控制字 0x90——D7 是方式标志位 1,D6D5 是 A 组方式 00,D4 是 A 口方向 1(输入),D3 是 C 口高四位方向 0(输出),D2 是 B 组方式 0,D1 是 B 口方向 0(输出),D0 是 C 口低四位方向 0(输出)。写程序前把这个字节在纸上拆一遍,比事后调试快得多。
3.2 扫描、消抖与键值映射的实现
扫描逻辑是逐列拉低、读行线。某一列被拉低时,如果该列上某个键被按下,对应的行线会被拉低,读 PA 口低四位就能知道是哪一行。键值按“列号×4+行号”编码,映射到 0 到 F 十六个编号。
#define BASE_8255 0x0600 #define PA (BASE_8255 + 0) #define PB (BASE_8255 + 1) #define PC (BASE_8255 + 2) #define CTRL (BASE_8255 + 3) /* 行线低四位中为 0 的那一位,换算成 0~3 的行号 */ static unsigned char row_index(unsigned char r) { switch (r & 0x0F) { case 0x0E: return 0; /* PA0 被拉低 */ case 0x0D: return 1; /* PA1 被拉低 */ case 0x0B: return 2; /* PA2 被拉低 */ case 0x07: return 3; /* PA3 被拉低 */ default: return 0xFF; /* 无键或串键 */ } } /* 返回 0~15 的键值,无按键返回 0xFF */ unsigned char scan_key(void) { unsigned char col, row, code = 0xFF; for (col = 0; col < 4; col++) { outportb(PC, (unsigned char)(~(1 << col) & 0x0F)); /* 逐列输出低电平 */ delay(1); /* 等列线电平稳定 */ row = inportb(PA) & 0x0F; if (row != 0x0F) { /* 有行线被拉低 */ delay(20); /* 20ms 消抖 */ if ((inportb(PA) & 0x0F) == row) { /* 复测一致才认 */ code = (unsigned char)((col << 2) | row_index(row)); while ((inportb(PA) & 0x0F) != 0x0F) ; /* 等待松手 */ } } } return code; }这里有三个参数值得说清楚。delay(1)是列线电平建立时间,机械触点加线间电容,几十微秒到一毫秒都正常,给 1ms 是保守值。delay(20)是消抖窗口,轻触开关的抖动通常在 5 到 10ms,20ms 足够覆盖,再大会让按键响应变迟钝。松手检测放在循环内部很关键,因为一旦跳出列循环,列线电平就变了,此时读 PA 口得到的行线状态没有意义,等松手必须和当前列的扫描状态绑定在一起。
注意:如果没有松手检测,一次按键会被重复识别成好几次,四位数码管上会看到同一个数字连续写进缓冲区。这是课堂演示里最常见的翻车现场。
3.3 数码管的动态扫描与显示缓冲区
四位数码管不是同时亮,而是分时轮流点亮,靠人眼视觉暂留伪装成同时显示。段码表用共阴编码,0 到 F 分别是 0x3F、0x06、0x5B、0x4F、0x66、0x6D、0x7D、0x07、0x7F、0x6F、0x77、0x7C、0x39、0x5E、0x79、0x71。
显示缓冲区用四字节数组disp[4],每次按键后整体左移一格,新键值填到最右。扫描函数每次只刷新一位,四个位轮完一圈约 8ms,刷新率 125Hz,远高于人眼闪烁阈值:
static unsigned char disp[4] = {0, 0, 0, 0}; /* 每次调用刷新一位数码管,需在主循环里高频调用 */ void scan_display(void) { static unsigned char pos = 0; outportb(PB, 0x00); /* 先灭段码,消除重影 */ outportb(PC, (unsigned char)((inportb(PC) & 0x0F) /* 保留键盘列线低四位 */ | (0x10 << pos))); /* 高四位输出位选 */ outportb(PB, seg_code[disp[pos] & 0x0F]); pos = (unsigned char)((pos + 1) & 0x03); } /* 新键值入缓冲区,旧值整体左移 */ void push_key(unsigned char key) { disp[0] = disp[1]; disp[1] = disp[2]; disp[2] = disp[3]; disp[3] = key; }先灭段码再换位选,这个顺序不能反。反过来的话,新位选打开的一瞬间,段码线上还是上一位的数据,会在新位置闪出一个残影,看起来就是数码管“串字”。(inportb(PC) & 0x0F)这个读-改-写操作也不能省,因为 PC 口低四位正在做键盘列线扫描,直接整体写会破坏扫描状态。分时复用同一个端口的两组信号,是这类实验最典型的资源冲突,处理方式就是读改写。
3.4 16×16 点阵的行列驱动与取模规则
16×16 点阵由四块 8×8 拼成。行信号由 SM5166 驱动,这颗芯片内置 3-8 译码器,8 路输出每路最大 2.5A,输出端有钳位电路,能压掉“列上拖影”;列信号由 SM16206 驱动,它是串行输入、并行输出的恒流驱动芯片,内部有移位寄存器和锁存器,时钟频率可以跑到 25MHz。所以列数据是串行送进去的,16 位数据分两次送完。
字模提取这块最容易出错。取模软件的设置必须和硬件接法严格对应:横向 8 个连续点构成一个字节,最左边的点是最低位 BIT0,最右边是 BIT7;16×16 汉字按每行 2 字节、共 16 行取模,每个汉字 32 字节;四块的存储顺序是左上角、右上角、左下角、右下角。这四条只要错一条,显示出来就是镜像、翻转或者乱码。
#define LED_SDI (BASE_8255 + 1) /* 串行数据口,接 16206 的 SDI */ #define LED_ROW (BASE_8255 + 2) /* 行选译码输出,接 5166 的 A0~A2 */ /* 逐行扫描显示,font 为 32 字节字模,row 取 0~15 * font[0..7] 左上半块 font[8..15] 右上半块 * font[16..23] 左下半块 font[24..31] 右下半块 */ void led16_scan(const unsigned char *font, unsigned char row) { unsigned char left, right; if (row < 8) { left = font[row]; right = font[8 + row]; } else { left = font[16 + row - 8]; right = font[24 + row - 8]; } outportb(LED_SDI, right); /* 级联先送远端:右半行 */ outportb(LED_SDI, left); /* 再送近端:左半行 */ outportb(LED_ROW, row); /* 3-8 译码选中对应行 */ delay_us(100); /* 每行点亮时间,决定亮度与刷新率 */ outportb(LED_ROW, 0xFF); /* 消隐,防止上一行残留 */ }16 行轮流点亮,每行 100 微秒,一轮 1.6ms,刷新率约 600Hz,肉眼完全看不到闪烁。先送右半行再送左半行,是因为两片 16206 级联,先进去的位会被推到后级,顺序反了左右就互换了。
调试点阵时我一般不走完整流程,先写一个 32 字节的“左上角单点”字模,看那个点是不是真的出现在左上角。位置对了再点亮一条对角线,确认行列方向和取模顺序;两条都过,再上真正的汉字字模。这样出问题时能立刻定位是驱动错还是字模错,不用对着乱码猜。
4. 定时与通信链路:8254 发声、8251 双机通讯与闭环调速
前面几个实验都是“写端口、读端口”,属于纯 I/O。这一组开始引入时间和反馈:8254 负责产生精确频率和定时中断,8251 负责两台机器之间的串行链路,步进电机和闭环调速则要在采样与执行之间形成回路。从这组实验开始,程序里出现“状态机”和“周期性任务”的雏形。
4.1 音符频率到计数初值的换算
声音的本质是方波,把某个频率的方波送到扬声器就发出对应音高。8254 的方式 3 是方波发生器,输出频率等于输入时钟除以计数初值。输入时钟取 1MHz 时,要得到 800Hz,计数初值就是 1000000÷800=1250。
这里有个精度问题需要提前想清楚。计数初值只能是整数,所以实际输出频率是 时钟÷取整后的初值。对高频音符,初值小,量化误差被放大:比如 1049Hz 算出来是 953.3,取 953,实际频率 1049.3Hz,偏差可忽略;但如果时钟只有 100kHz,同样的音符算出初值 95,误差立刻放大十倍,听起来就明显走音。所以时钟源要在整个音域内保证分辨率,1MHz 是个合理的选择——最低音几十赫兹时初值上万,最高音接近两千赫兹时初值还有五百左右。
方式控制字 0x36 的含义可以在纸面上拆开:D7D6=00 选中计数器 0,D5D4=11 表示先读写低字节再读写高字节,D3D2D1=011 表示方式 3,D0=0 表示二进制计数。拆分过程本身就是一次对 8254 控制字格式的复习,比直接抄代码记得牢。
4.2 频率表加时间表驱动的播放循环
一首曲子拆开就是“音高序列”加“时长序列”。频率表按曲谱顺序存每个音符的频率,时间表存对应的相对时长,两张表一一对应,末尾用 0 作为曲末标志。播放循环每轮从表里取一个频率算初值、装计数器,再取一个时长调延时子程序:
#define BASE_8254 0x0640 #define T0 (BASE_8254 + 0) #define TCTRL (BASE_8254 + 3) #define CLK_IN 1000000UL /* 计数器 0 输入时钟 1MHz */ /* 中音区 C 大调频率表,末项 0 为结束标志 */ static const unsigned int freq_tab[] = { 262, 294, 330, 349, 392, 440, 494, 523, 587, 659, 698, 784, 880, 988, 0 }; /* 与频率表一一对应的相对时长,以单位延时为单位 */ static const unsigned char time_tab[] = { 4, 4, 8, 8, 4, 4, 8, 4, 4, 8, 8, 4, 4, 8, 0 }; void play(void) { unsigned int i, cnt; for (i = 0; freq_tab[i] != 0; i++) { cnt = (unsigned int)(CLK_IN / freq_tab[i]); /* 算计数初值 */ outportb(TCTRL, 0x36); /* 计数器0,方式3,先低后高 */ outportb(T0, cnt & 0xFF); /* 低字节 */ outportb(T0, (cnt >> 8) & 0xFF); /* 高字节 */ delay_units(time_tab[i]); /* 按相对时长保持 */ } outportb(TCTRL, 0x30); /* 停止发声 */ }delay_units的单位延时必须实测校准。软件延时循环的次数和 CPU 主频强相关,同一段代码换台机器节奏就变了。我通常的做法是先用秒表或示波器量出单次单位延时的实际毫秒数,再反推时间表里的数值,这样换机器重编一次就行,不用改表。
两张表的长度必须一致,这是最容易出问题的地方:频率表多一项、时间表少一项,循环读到越界数据,现象是曲子放到一半突然变调或者卡死。写完表之后数一遍元素个数,比运行起来调试快。
4.3 8251 双机通讯的波特率与握手时序
双机通讯需要两台实验装置,一台发一台收。发送机把内存 3000H 到 3009H 共 10 个字节发出去,接收机收到后直接打印。8251 是异步串行接口,它需要一路独立的时钟输入,通常由 8254 的一个计数器分频产生。
标准做法是用 1.8432MHz 晶振做 8254 计数器 1 的输入,这个频率是串口通信的“整数母”:1843200÷9600=192,整除;换成其他常见波特率也基本都能整除。计数器 1 工作在方式 2,初值 192,输出 9600Hz 的方波,作为 8251 的时钟,配合 ×16 的波特率因子,实际通信速率就是 9600bps。
#define BASE_8251 0x0680 #define D8251 (BASE_8251 + 0) /* 数据口 */ #define C8251 (BASE_8251 + 1) /* 命令/状态口 */ #define T1 (BASE_8254 + 1) #define TCTRL (BASE_8254 + 3) void uart_init(void) { outportb(TCTRL, 0x76); /* 计数器1:方式2 分频,先低后高 */ outportb(T1, 192); /* 1.8432MHz / 192 = 9600Hz */ outportb(T1, 0); /* 高字节 */ outportb(C8251, 0x00); /* 软件复位序列,连写三个 0 */ outportb(C8251, 0x00); outportb(C8251, 0x00); outportb(C8251, 0x40); /* 内部复位命令 */ outportb(C8251, 0x8E); /* 模式字:异步/1停止位/无校验/8位/×16 */ outportb(C8251, 0x15); /* 命令字:允许发送+允许接收+错误复位 */ } /* 发送一块数据,靠查询 TxRDY 判断发送缓冲是否空闲 */ void send_block(unsigned char *buf, unsigned int len) { unsigned int i; for (i = 0; i < len; i++) { while ((inportb(C8251) & 0x01) == 0) ; /* 等 TxRDY 置位 */ outportb(D8251, buf[i]); } }模式字 0x8E 和命令字 0x15 这两个常量,建议自己按位推一遍:0x8E 拆成二进制 1000 1110,D1D0=10 是 ×16 因子,D3D2=11 是 8 位数据,D7D6=10 是 1 位停止位;0x15 拆成 0001 0101,D0=1 允许发送,D2=1 允许接收,D4=1 清错误标志。推一遍之后,换波特率或者改数据位宽时就知道改哪一位。
收发两端的所有参数必须完全一致,包括波特率因子、数据位、停止位、校验方式。任何一项不同,现象都是收到乱码或者一个字节都收不到。排查时先做自环:把发送口和接收口短接,自己发自己收,能通就说明本机设置正确,问题在对方或者连线上。
4.4 步进电机的节拍表与闭环控制的三段式结构
步进电机是开环控制,靠脉冲分配表驱动。四相八拍的通电顺序是一组固定编码,每来一个脉冲换一拍,转子就转一个步距角:
/* 四相八拍节拍表,写在 8255 的 PB 低四位 */ static const unsigned char step_tab[8] = { 0x01, 0x03, 0x02, 0x06, 0x04, 0x0C, 0x08, 0x09 }; /* 走 steps 步,方向由 dir 决定(0 正转,1 反转),interval 为换拍间隔 */ void step_run(unsigned int steps, unsigned char dir, unsigned int interval) { unsigned int i; unsigned char phase = 0; for (i = 0; i < steps; i++) { outportb(PB, step_tab[phase] & 0x0F); /* 输出当前拍 */ delay_us(interval); /* 间隔决定转速 */ phase = (unsigned char)(dir ? (phase + 7) & 0x07 /* 反转退一拍 */ : (phase + 1) & 0x07); /* 正转进一拍 */ } outportb(PB, 0x00); /* 断电,防电机发热 */ }换拍间隔直接决定转速,间隔太短电机会失步,会发出“嗡嗡”声却不转,这是最常见的现象。失步的临界值和电机型号、负载、驱动电压都有关系,通常从 2ms 往上试,逐步往下压,找到不失步的最小值。
温度闭环和直流电机调速这两个实验,结构是同一套。温度闭环里,传感器采集当前温度,和设定值比较,偏差送进增量式 PID 算法,输出的控制量去驱动加热或散热;直流电机调速里,采样换成转速(编码器计数或者测速发电机),输出换成 PWM 占空比。三段式结构
本文还有配套的精品资源,点击获取