68HC11打印ASCII字符串

这篇文章阐述字符串在内存中的结构,并讲解如何在电脑上通过C语言和单片机上通过汇编打印字符串。这篇文章将使用68HC11作为汇编的例子.

--by Captdam @ Sep 2, 2024

[en] Here is the English version of this article

几年前我在大学担任助教时写下了这一篇文章。那是一门关于RISC与CISC单片机架构与68HC11汇编的课程,也就是对标国内计算机专业与电子工程的微机原理的课程.我最近在整理我的Gist时发现了这一篇文章,我觉得有意义把它搬运到我的网站上。

这里有一个打印ASCII编码字符串的作业,很多学生在这个作业上遇到了困难。我在回复邮件时觉得在邮件内向每个提问的学生单独解释很困难也很低效,于是我就写下了这一篇文章来梳理这个作业并阐述单片机如何工作。

一些建议

我想首先提出这几点,我觉得很有用也很必要。

首先,把参考手册打印出来。在对单片机编程时,参考手册是最重要的工具,它包含了所有我们可以使用的指令集与怎样使用这些指令。如果你想要了解更多细节,去理解单片机的硬件是如何工作的,那么也可以参考手册。这里是手册的链接:https://www.nxp.com/docs/en/reference-manual/M68HC11RM.pdf

第二,在模拟器中使用“步进”功能,不要觉得程序会一次跑成功。99.99%的情况下,第一次写出来的程序都会有过多过少的Bug。如果我写的程序一次就跑起来了,我肯定觉得有鬼,肯定有什么难找的大Bug。所以,设置一些断点并一步一步执行代码。和高度抽象(指底层被封装不需要关心)的PC不同,单片机依靠不同的硬件模组相互协同工作,我们需要时刻注意所有的寄存器。

第三,每次运行时都要重置模拟器来清理内存。

字符串编码

对于PC

让我们从高级的PC编程来讲起。当编写软件时,我们将会有数据与代码两个部分。数据用来保存我们所需操作的信息,代码则包含了如何操作我们的数据。而这里又分为两种数据:在内存中,可以随时被修改,并且在运行时才会被创建的的变量;与存在于ROM中,在编写程序时就创建的,只读的常量(对于哈弗架构来说)。在这一篇文章中,我们只考虑ROM中只读的常量数据。让我们来看看下一个C语言的例子:


const char const* myString = “This is some data.”;
printf(“%s”, myString);
	

在这一个程序中,myString就是我们的数据,它的内容就是我们想要打印的字符串。printf是我们的代码,它指示了如何操作这个数据。

当我们编译这个程序,编译器首先将会把我们的数据This is some data按照ASCII编码(ASCII是一种常用的将字符编码的格式)。这个被编码的数据将会被储存在我们的程序中的某个地方,编译器将会记住那个地方的地址。

让我们假设这个数据被储存在了地址0x1234(0x指十六进制编码,我们也可以使用$)。确切地说,有一段储存被用作储存这一段文字,第一个字符‘T’在地址0x1234,第二个字符‘h’在0x1235,第三个字符‘i’在0x1236,以此类推。第一个字符的地址0x1234被应用于数据的名字myString。对于编译器来说,当我们提到数据myString,指的就是地址0x1234

接下来,编译器继续编译代码:printf(“%s”, myString);包含了两部分:首先,“%s”表示按照ASCII的格式来理解数据。更系统地来说,以0符作为结尾的ASCII编码字符串。

第二个部分myString表示数据的地址。在我们例子中,myString就是0x1234。所以,计算机就会从0x1234开始读取数据并按照ASCII编码表对应的字符进行打印。打印完0x1234包含的字符后,就前往下一个字符,即打印0x1235包含的字符,以此类推。

那么,什么时候停下来呢?换句话说,计算机如何知道字符串的结尾?当我们对字符串进行编码时,我们就会放一个0符在字符串结尾。当计算机读到这个0符,计算机就知道这是字符串结尾了,于是就会停止打印。

对于单片机


myString
	fcc	'This is some data.'
	fcb	0
	

对于单片机来说,我们在汇编中使用和C语言一样的方案。让我们看看上面的例子。

首先,我们创建了一个叫做myString的标签。和C语言一样,汇编器会将其转换为一个地址。

这里,fcc是一个伪指令。它不能被转换为一个单片机能读懂的机器码,相反,它被用来指示汇编器。这里的fcc表示接下来的字符串应该被按照ASCII编码转换为二进制编码。在我们的例子中,‘This is some data.’就会被按照ASCII储存在我们的程序中。注意,这里还没有在末尾加上0符。

加下来的fcd也是一个伪指,他告诉编译器将接下来的数值直接放进程序中。在我们的例子中,也就是数字0,即0符。

下面展示了目前程序空间中内容。我们假设在我们的数据前还有6字节其他内容:


Real Address	Address relative to label	Content
0x05		myString-1			Something before our string
0x06		myString			T	(First character in the string is located at message+0)
0x07		myString+1			h
0x08		myString+2			i
0x09		myString+3			s
......
0x15		myString+22			t
0x16		myString+22			a
0x17		myString+23			.	(Last character of the string)
0x34		myString+24			\0	(Null-terminator)
0x35		myString+25			Something after our string
	

打印一个字符

在电脑上,我们可以依靠底层操作系统来显示数据。我们将要显示的数据放在一段内存中,然后使用一个system call并将我们想要显示的数据的地址(也就是指针)也传递给系统。接下来,系统就会用这个指针来读取我们传递的数据并在一些列底层操作后将数据展示在屏幕上。

不过单片机的情况有所不同。这里没有操作系统,我们需要自己来做所有的底层操作。甚至,呃,这里没有显示器……如何显示我们的数据呢?我们将使用外接设备:一个串口。我们通过这个串口来讲数据发送给一台计算机并使用串口监视器来显示数据,或者一张支持串口输入的LCD。

我们的单片机内置了UART。除去初始化相关硬件外,我们只需要将想要打印的字符写入一个地址为0x102F的特殊寄存器SCDR就可以发送这个字符到外接显示器了,如参考手册所解释。


	ldaa	'X'
	staa	$102f
	

在上面的例子中,我们首先将字符‘X’放进累加器A,然后再将累加器A中的内容,也就是字符‘X’的ASCII编码对应的二进制数,写入地址0x102F。这个写的操作就会触发UART发射器的硬件将字符发送出去。当然,在写之前我们还需要检测UART发射器当前状态。如果UART还在发送上一个字符,我们就应该等待其完成后再写入新的字符。

你也许有这样的经历,写完代码后过两天发现自己也看不懂当时写了啥。想象你刚度假回来打开这个代码,然后你发现“0x102F这个数字是什么鬼”。所以,让我们把这段代码封装成一个子程序。

X
sendChar
	staa	$102f
	rtsX
	

这个子程序将把累加器A的内容写入UART的数据寄存器来触发硬件的发送功能。现在开始,我们可以不再管UART的技术细节,我们只需要将要要打印的字符装入累加器A并执行sendChar这个子程序就可以了。下面展示了一个打印OK的例子。


	ldaa	'O'
	jsr	sendChar
	ldaa	'K'
	jsr	sendChar

sendChar
	staa	$102f
	rts
	

实际上,这也是在电脑上void putc(char c)方程的封装方式:将需要打印的字符读入寄存器,并执行这个方程来打印该寄存器中的内容。

打印字符串

虽然我们可以像上面的OK例子那样一个字符一个字符地打印,但是效率太低了。

如上文所提到,字符串后面是有一个0符作为结尾的。所以,我们可以依次打印字符串内的字符,知道我们看到这个0符。

首先来看看我们如何用C语言来实现:


const char const* myString = “This is some data.”;

for (const char* p = myString; *p != '\0'; p++) {
	putc(*p);
}

// Or, more detail

const char const* ptr = myString;
while(1) {
	char current = *ptr;
	if (current == '\0') break; // '\0' is zero-terminator and it represents number 0
	putc(current);
	ptr++;
}
	

编译后,变量myString将包含数据的指针,也就是第一个字符的地址。我们将读取第一个字符,打印第一个字符,移动指针,读取第二个字符,打印第二个字符,以此类推。直到我们读到0符,停止打印。

接下来,我们将这一段代码改写为68HC11的汇编:


	ldx	myString	; Load address of data to index register (pointer)
loop
	lda	idx, 0		; Load A fron pointer
	beq	done		; If previous load is 0 which sets zero flag, branch to done label
	jsr	sendChar	; Print character
	inx			; Advance pointer
	bra	loop		; Continue the loop
done
	bra	*		; halt

sendChar
	staa	$102f
	rts

myString
	fcc	'This is some data.'
	fcb	0
	

68HC11有两个特殊的16比特寄存器,即指针寄存器IXIY(早前的6800系列型号只有1个指针寄存器)。指针寄存器被用来通过地址访问内存,就如同C语言里的指针。

让我们一起来梳理一下这一段代码:

首先我们将我们的数据myString的地址装入指针寄存器IX。现在,IX的内容就是第一个字符T的地址了。

接下来,我们通过指针IX加上0偏移来读取数据(也就是指针所指向的字符T)到累加器A里面。

参考手册在这一页所说,lda指令将修改0状态。换句话说,这个0状态将根据读入累加器A里面的数据是否等于0而修改。如果0状态为真,那就表示我们读到了0符,我们应该停止当前任务。否则,我们就应该继续打印当前字符并读取下一个字符。下面的beq指令将会在0状态下将程序跳转到done标记,也就等于跳出循环了。

现在,我们要打印的字符已经被载入累加器了。我们可以执行sendChar子程序来打印这个字符。

最后,我们移动指针到下一个字符并且回到循环的开始,继续上面解释的操作。

我们可以给打印字符串的代码也封装成一个子程序:


ldx	str1
jsr	sendString
ldx	str2
jsr	sendString

sendString			; Print string pointed by IDX
loop
	lda	idx, 0		; Load A fron pointer
	beq	done		; If previous load is 0 which sets zero flag, branch to done label
	jsr	sendChar	; Print character
	inx			; Advance pointer
	bra	loop		; Continue the loop
done
	rts
	

sendChar			; Print character in ACCA
	staa	$102f
	rts

	
str1
	fcc	'111'
	fcb	0
str2
	fcc	'222'
	fcb	0
	

在这个例子中,我们需要首先将数据的指针装入指针寄存器IX中再执行子程序来打印使用0符结尾的字符串。