🏠Homepage🏠 | 🔥GitHub🔥

写给程序员的 8051 汇编指北🧭

写完漫无止境的单片机作业我以后有了巨量心得(都是血的教训啊),所以想写点东西总结一下。目前网上目前的教程基本都是面向对计算机没什么了解的人,普遍侧重于实例的教学,而这篇文章主要从程序员的角度出发考虑 8051 单片机的软硬件设计以及其程序的编写思路。阅读本文并不需要很多知识,除了掌握任意语言的程序编写以外只需要懂一些数字电路知识就好。

为什么叫「指北」呢?实际上我也不太想写一个事无巨细的「指南」,毕竟很多东西有人写过了,只需要给读者一个链接作为补充就可以。而很多写实验的心得是网上都没见过的,那些是我真的想记录下来的内容。

汇编程序的组织结构

如果熟悉任何一种指令集上的汇编可以跳过本节,直接进入 我的血泪史 环节

众所周知,程序是由控制流操作和数据操作组成的,前者包括顺分支判断、循环、函数调用等,后者呢则包括算术运算、逻辑运算、赋值操作等。而汇编也是一样,只不过相比于常见编程语言来说使用的记号非常的……抽象……比 Linux 中的各种缩写还要抽象。比如赋值操作通常被称为 mov,而它其实是 move 的缩写。而我们在 C 中所见过的 goto 语句通常被称作 jmp, 是 jump 的缩写。了解了其命名规则后,在阅读代码时就可以先通过缩写猜测一下指令的意思,如果有困惑再去查表,写代码时也可以帮助记忆。

在国内的汇编资料中 mov 常被称为「传送语句」,这里为了方便理解用了程序员常用的「赋值语句」

顺便这里给出一份 8051 的指令集,方便读者在读后文时查阅。至于阅读方法嘛……表格中每一行就是一条指令,第三列是指令的名字,第四列是参数格式。最终指令写出来的格式就是名字加上参数,比如这些例子与对于的 C 语言说明:

; 用分号表示行注释
add a, #30H  ; a += 0x30
add a, 30H   ; a += *((int*)0x30)  // 直接寻址
add a, r0    ; a += r0
add a, @r0   ; a += *r0            // 间接寻址
; 汇编和 sql 语言、basic 语言一样不区分大小写

参数格式中的逗号用来分隔参数,direct 表示可以填一个内存的地址,immed 表示可以填入字面量数据(比如十进制数字 10, 二进制数字 0101B, 十六进制数字 0FH, 字符 '?'),offset 和 addr 可以填入自定义的跳转标签, bit 可以填入一个可按位访问字节的其中一位(比如寄存器 A 的最低位 acc.0, p0 的最高位 p0.7),其他的内容原封不动抄上即可。具体解释 这里 也有。

好,回到上文讨论的控制流操作和数据操作之上。 数据操作比较简单,和常见变成语言的操作没啥区别,多看看表就行。 比较有趣的是控制流操作,汇编中并没有块结构,只能通过跳转指令规划控制流,所以在常用编程语言里常见的控制流结构在汇编中一般是由一组语句构成的,这里给出一些常见的对应思路:

; do { ... } while(a) 结构,因为简单倒是最常用的
doWhileStart: ; 定义跳转标签
; ...代码块
jnz doWhileStart ; 如果 a 不是 0 则跳转至 doWhileStart

; if (a) { ... } else { ... } 结构,可以根据需要裁剪分支
jz ifElse ; 如果 a 为 0 跳转
; ...代码块A
sjmp ifEnd ; 无条件跳转(s 是 short 的缩写)
ifElse:
; ...代码块 B
ifEnd:

; for (a = 0; a != 4; a++) { ... } ,可以通过裁剪变成 while (a) { ... }
mov a, #0
forStart:
cjne a, #4, forEnd ; Compare and Jump if Not Equal 的缩写
; ...代码块
; 如果想 break 可以 sjmp forEnd,如果想 continue 可以 sjmp forStart
inc a ; increase
sjmp forStart
forEnd:

函数调用在汇编中比较特殊,当执行到 lcall 时,当前运行的指令位置被压入调用栈中并跳转到标签位置继续运行,当执行到 ret 时,从栈顶取出调用方的地址并跳过去。而且在 lcall 执行的时候并不会修改寄存器的内容,所以通过寄存器传递参数是很常见的选择。

; double(2);
mov a, #2
lcall double

; int double(int a) { return a + a; }
double:
mov r0, a
add a, r0
ret

由于从任何一个标签开始一直到第一个被执行到的 ret 指令之间都是函数体,所以可以有函数被调用了以后先干一点初始化操作再从函数的中央开始切一半下来递归。不过这种操作有点复杂这里就不举例子了。

而一个汇编程序的基本结构就是:

org 00H ; 代码摆放位置
ljmp init

org 30H
init:
; 初始化程序
start:
; 循环工作
sjmp start

; 自定义函数

end

单片机上电以后就会从地址 0 开始一直不停执行代码,而上面把主程序放在地址 30H 并从地址 0 跳转过去是因为中间这段地址会被作为中断程序的入口。

指令集与地址空间设计

我相信有对 x86 汇编稍有了解的读者像我一样在才开始尝试写 8051 汇编程序的时候会各种 illegal operator 比如想当然写出下面的代码就会编译报错:

delay:
push r1 ; 把 r1 压入栈中
mov r1, r0
djnz r1, $ ; $ 被编译成本条语句的开头地址
pop r1 ; 恢复 r1
ret

这个函数因为修改了 r1,由于调用它的函数有可能正在使用这个寄存器,所以需要把它保存起来,使用完以后再恢复。不过直接这样写是不行的,查询一下指令集就会发现只有 push direct 并不存在 push R1 ,同样也不存在 pop R1mov R1, R0。我当时就挠头了,不能 mov R1, R0 我倒是可以理解,毕竟指令集规模有限,用下面的代码迂回替代即可:

mov a, r0
mov r1, a

但没有 push R1 只有 push direct 是实在让我感到困惑,毕竟 push 这条指令最大的用途就是保存寄存器,我实在是想不到什么时候需要直接 push 一个固定的内存单元。我一度觉得这是个很傻的设计,直到有一天我仔细琢磨了 8051 的存储模型。8051 有 256B 的运行时内存,而有趣的是它的 00H 到 07H 地址正好就是寄存器 R0 到 R7 的映射,也就是说如果我们想 push r1 那么就可以写成 push 01H。进一步讲,其他寄存器也都有相应的内存映射,不过它们都在 80H 以上的地址了,而且它们中的一些有单独的名字,比如寄存器 A 用作内存地址的时候就写为 ACC。这样的设计可以使 push 只占用一个操作符完成针对通用寄存器、特殊寄存器、内存地址的功能覆盖。

默认情况下 R0 到 R7 是映射到 00H 到 07H 而 SP 也就是栈顶位置指向 07H。不过可以修改 PSW 寄存器的工作寄存器设置使 R0 到 R7 映射到其他位置,可选择从 08H, 10H 或 18H 作为映射起点。而修改工作寄存器以后需要在初始化单片机的时候把 SP 往后改到合适的位置。关于内存空间的更多信息可以参考 这份资料

然后我就发现指令集里还有其他很多类似的指令,都可以用这样的方法解释。比如,djnz 指令并不能直接 djnz a, label 但是可以用 djnz acc, label 迂回一下;再比如 cjne a, r0, label 也是不存在的指令但是可以使用 cjne a, 00H, label 替代。仔细观察整个指令集可以发现 opcode 有 8 位最多容纳 256 条指令,而目前看来指令集被塞得满满的。而所有 direct 参数在 opcode 以外需要额外一个字节来存,这样处理在代码里出现频率没那么高的指令就可以为更常用的指令让出 opcode 从而减小二进制程序的大小,毕竟 8051 的内置 ROM 只有 4KB 大小,按照指令普遍长度填满也就只能放两千多条指令,这点存储空间对于大点的工程来说简直就是洒洒水,还得上外置代码存储器。实际上,我检查这学期写过的实验中最大的一个编译产物达到了 1.48KB ,而里面主要也就是一个屏幕显示控制和一个 I2C 协议实现,所以功能再复杂一点填满 4KB 还是相当容易的。

所以上面的代码正确写法应该为:

delay:
push 01H ; 把 r1 压入栈中
mov r1, 00H
djnz r1, $ ; $ 被编译成本条语句的开头地址
pop 01H ; 恢复 r1
ret

一些建议

同一个项目中保持一致的代码风格,不管是大小写风格,标签命名风格,或者是函数备份寄存器的压栈由调用方还是被调用方操作,进位符在操作前清空还是操作和清空……这些问题在很多语言中都有一定要求所以不成问题。但是像汇编这样不受重力束缚的语言咋写完全看你的心情,这时候就要保持克制而统一的编码风格。不然未来的你将遭到现在的你的背刺「这写的是啥啊,真是我写的么」。我这段时间最夸张的一次上午写的代码下午就看不懂了,那是一段控制显示屏的代码,里面充满了代表操作指令的 magic number 而且我当时没写注释。

很多时候我们高估了自己对复杂的控制能力,也难以预估未来的复杂度增长。为了偷懒少写注释或者脑记函数调用间的复杂寄存器占用情况,这些短时间能被我掌控而杯忽略的复杂度常常在未来的某一天给我带来灾难,或是从零开始重读自己写的代码,或是调试了一下午发现 bug 只是因为寄存器冲突……尽管我们作为渺小的人类无法完全避开目光短浅,但我还是希望长鸣的警钟能让我犯的错少一点再少一点。

除了寄存器以外还有一个例子是用户内存空间的管理,有时候想管理大块共享数据的时候跨函数的公共空间还是很有必要的。这时候就很建议实现一个简单的内存管理系统,按照现代计组与操作系统的习惯低地址放栈自下向上生长,高地址放堆自上而下生长,然后堆仅通过固定的一组内核函数操作,用户态函数仅通过间接寻址访问堆内存。而如果有外部 RAM 的情况下可以进一步让内部 RAM 仅作为栈,把堆放在外部 RAM 里。像这样通过借用现代思想为内存操作分层,能大大降低操作内存时的心智负担。

另一个我想分享的小技巧是在编写通讯协议的时候,时序图是真的好用。上学期我初学时序图的时候觉得困惑,有了状态图还要时序图干嘛呢,多鸡肋啊!殊不知时序图除了表示状态变化以外最大的不同是上面可以标识变化与等待的现实时间。而每一步操作之间要等多长时间恰恰是很多通讯协议的关键所在,在很多没有同步时钟的单线通讯情况下尤为如此,所以写出程序发现元器件无响应不妨去仔细再读读时序图并算算各个操作之间的时间是否符合要求。另外,看多根线的密集操作时序图建议配把尺子「这根线上升沿的时候那根线应该是啥状态来着」。

以及,最最最最最惨痛的教训:8051 的 P0 口和其他口不一样,置 1 的时候是高阻态而不是高电平,如果想当输出要接上拉电阻!!!

其他需要学的特色功能列表