51单片机驱动辉光管时钟

作为一个老二次元技术宅怎么能拒绝拥有一台世界线变动率探测仪呢?那就用51单片机和辉光管做一个时钟吧。

--by Captdam @ Feb 14, 2018

硬件设计

控制端电路

首先,作为控制器核心使用89C52RC单片机。这是一款基于Intel MCS-51架构(也就8051)设计的复杂指令集8位单片机,提供4个IO Port共32个可操作的读写IO。使用这一款单片机的原因包括它提供了足够的IO,相对低廉的价格,以及我之前为了学51单片机买了一大堆芯片没地方用。、

不过玩笑归玩笑,51单片机那量大管饱的IO数量确实很适合这个项目。每个辉光管能显示0-9共十个数字,且同时只会有一个数字被点亮,那么操作一个辉光管就需要log2(10) = 3.3条IO,即4bit。那么一个port为8-bit,就能操作两位数,3个port就可以操作时分秒共6位数。剩下一个port我们可以作为调节时间的输入。

控制端电路
控制端电路

如图所示,P0、P1与P2分别作为秒、分、时的控制信号输出,低4位为个位,高4位为十位。举一个例子,在15:22:08:

P3的3-7比特分别为时针前移,分针前移,秒针重置,分针后移,时针后移。此外,因为51单片机的P0没有上拉电阻,于是使用外部桑拿R1到R8。在输入的P3,加入R9-R14作为输入信号弱上拉。

驱动端电路

驱动端电路
驱动端电路

在控制端的单片机输出的二进制信号需要被转换为独冷(One-cold)信号才能有效控制辉光管内每个数字的开断。因此,使用74LS138解码器进行信号转换。此外,辉光管需要使用150V以上的高压驱动,而单片机/解码器的电压只有5V。考虑电压非常高,使用物理隔绝的继电器控制200V输入辉光管。根据辉光管的资料(取决于具体型号),加入一个20k欧姆的电阻R15进行限流保护。

也可以使用独热(One-hot)信号的74LS238解码器进行信号转换,更换继电器的公共端即可。独冷共阳,独热共地。

解码器可以提供足够的电流驱动继电器,所以就不需要添加MOS驱动电路了。

驱动端电路板
驱动端电路

上图展现了作为驱动电路的电路板,四周十个蓝色的就是继电器,中间两张芯片为解码器。我没有直接将解码器焊接在电路板上而是使用插座,这样即使解码器坏掉了也可以方便更换。

软件设计

软件设计并不复杂,首先我们为时分秒分别创建一个8-bit的变量。

固定操作,系统上电后我们就初始化数据内存与IO。此外,设置计时器中断,让计时器每秒钟中断一次。

在中断中,我们让储存秒针的变量加1。如果秒针达到60,就重置秒针为0,并为分针加1。如果分针达到60,就重置分针为0,并为时针加1。如果时针达到24,就重置时针为0。小学一年级数学,只不过是用程序实现了。

除此之外,还要加入调整输入检测,如果有输入,则相应地改变时分秒。我们可以为输入按键编写单独的中断,但是这会增加程序复杂度,而且可能因为处理按键而不能即时处理时钟的中断导致时间不准确。所以,我们决定在时钟中断的末尾加入一个轮询。

51单片机是12T单片机,也就是说,核心时钟每振荡12次执行一次操作。外接晶体频率为11.0529MHz,那么一秒钟单片机将执行:


1s * (11.0529MHz / 12) = 1s * 11.0529M op/s / 12 = 921075 ops
	

51单片机的内部计时器为16-bit计时器,也就是做最大容量为65k,明显低于上面的921k。因此,我们将需要用软件的方式扩充计时器到24位。


921075 = 100 * 9210
	

计时器每9210个周期中断一次并将计时器软件高位加1。如果中断到100次(计时器软件高位达到100),那么就重置计时器计时器软件高位并执行每秒钟应该执行的任务。


if (--time == 0) {
	time = 100;
	do_task();
}
	

我使用Keil的汇编语法来编写这个驱动:

配置


	TIME EQU 0xDC0A
	TCYL EQU 0x64
	

常数TIME为计时器每次中断重载的数据,其值为0xDC05,为16-bit最大数0xFFFF减去9210再加上中断发生后到实际重载这个数时额外消耗的5个周期。因为时钟向上计时,所以这里做减法。

常数TCYL为计时器需要中断的次数,其值为0x64,即100次。


	TIMER DATA 0x23
	MEM_H DATA 0X22
	MEM_M DATA 0x21
	MEM_S DATA 0x20
	IO_H EQU P2
	IO_M EQU P1
	IO_S EQU P0
	UI EQU P3	
	

将计时器软件高位TIMER、时针MEM_H、分针MEM_M、秒针MEM_S储存在内存0x23-0x20的区域内。

为IO Port根据实际用意取别名:输入调节时间UI、时针IO_H、分针IO_M、秒针IO_S

中断表


	ORG 0x0000
	JMP INI
	ORG 0x001B
	JMP PROCESS
		

上电执行INI程序。

时钟中断执行PROCESS程序。

INI - 初始化


INI:
	MOV IO_H, #0x00
	MOV IO_M, #0x00
	MOV IO_S, #0x00
	MOV MEM_H, #0x00
	MOV MEM_M, #0x00
	MOV MEM_S, #0x00
	

重置IO输出为0,重置内部变量为0.


	MOV TMOD, #0x10					;Enable time interupt (16-bit mode)
	MOV TL1, #LOW TIME				;Set timer
	MOV TH1, #HIGH TIME
	MOV TIMER, #TCYL				;Set timer cycle counter
	SETB TR1
	SETB ET1
	SETB EA
	JMP $
	

初始化时钟硬件,并装入时钟的初始值TIME0xDC0A)时钟软件高位的的初始值TCYL0x64)。

启用时钟中断。

初始化完成,使用忙等待停止程序执行。

也可以使用睡眠模式来进行等待,更省电。不过,需要注意某些睡眠模式会停止内部时钟,且从睡眠模式恢复将需要消耗额外的时钟周期,需要相应调整时钟的初始值TIME

PROCESS - 时钟中断 - 软件高位


PROCESS:
	MOV TL1, #LOW TIME
	MOV TH1, #HIGH TIME
	

重载时钟的初始值TIME


	MOV A, TIMER
	DEC A
	MOV TIMER, A
	JZ OK
	RETI
	OK:
	MOV TIMER, #TCYL
		

if (--time == 0) {
	time = 100;
	do_task();
}
		

时钟软件高位TIMER减1并判断是否达到中断数量。可以参考等效的右侧C代码。

PROCESS - 时钟中断 - 时钟计算


	MOV R0, MEM_S
	INC R0
	MOV MEM_S, R0
	CJNE R0, #0x3C, SETTING
	MOV MEM_S, #0x00
	
	;Minute++
	MOV R0, MEM_M
	INC R0
	MOV MEM_M, R0
	CJNE R0, #0x3C, SETTING
	MOV MEM_M, #0x00
	
	;Hour++
	MOV R0, MEM_H
	INC R0
	MOV MEM_H, R0
	CJNE R0, #0x18, SETTING
	MOV MEM_H, #0x00
		

mem_s++;
if (mem_s == 60) {
	mem_s = 0;
	mem_m++;
	if (mem_m == 60) {
		mem_m = 0;
		mem_h++;
		if (mem_h == 24) {
			mem_h = 0;
		}
	}
}
		

从内存中读取时针MEM_H、分针MEM_M、秒针MEM_S并进行时间计算。首先计算秒针MEM_S加1,如果溢出(到达60),则归零秒针MEM_S并计算分针MEM_M加1。以此类推,可以参考等效的右侧C代码。

PROCESS - 时钟中断 - 调节时钟输入


SETTING:
	;Get UI input
	MOV UI, #0xFF
	NOP
	NOP
	MOV R1, UI

	;Check bit7: Hour++
	SET_H_INC:
	MOV A, R1
	ANL A, #0x80			;Leave the value of bit7, clear others (set to 0)
	JNZ SET_M_INC			;Bit is not 0, skip current
	MOV R0, MEM_H
	INC R0
	MOV MEM_H, R0
	CJNE R0, #0x18, SET_M_INC	;Overflow, reset
	MOV MEM_H, #0x00

	;Check bit6: Minute++
	SET_M_INC:
	MOV A, R1
	ANL A, #0x40
	JNZ SET_S_RST
	MOV R0, MEM_M
	INC R0
	MOV MEM_M, R0
	CJNE R0, #0x3C, SET_S_RST
	MOV MEM_M, #0x00

	;Check bit5: Second = 0
	SET_S_RST:
	MOV A, R1
	ANL A, #0x20
	JNZ SET_M_DEC
	MOV MEM_S, #0x00

	;Check bit4: Minute--
	SET_M_DEC:
	MOV A, R1
	ANL A, #0x10
	JNZ SET_H_DEC
	MOV R0, MEM_M
	DEC R0
	MOV MEM_M, R0
	CJNE R0, #0xFF, SET_H_DEC
	MOV MEM_M, #0x3B

	;Check bit3: Hour--
	SET_H_DEC:
	MOV A, R1
	ANL A, #0x08
	JNZ UPDATE
	MOV R0, MEM_H
	DEC R0
	MOV MEM_H, R0
	CJNE R0, #0xFF, UPDATE
	MOV MEM_H, #0x17
		

if (ui & (1<<7)) { // Bit 7 for hour++
	mem_h++;
	if (mem_h == 24) {
		mem_h = 0;
	}
}
		

首先上拉输入IO,等待2个时钟周期后在读回输入值,接下来使用比特和来作为掩码来测试外部按键输入。如果按键被按动(比特和操作结果非零),就执行相应操作。

以时针加1为例,如果输入的第7比特不为零,那么就从内存中读取时针MEM_H并加一写回。如果溢出了(达到24 0x18),那么就归零。此外,这里程序中,判断溢出在写回操作之前。如果溢出,则会再写一遍。这是为了方便编写代码,减少创建label与跳转指令的使用。可以参考等效的右侧C代码。

PROCESS - 时钟中断 - 输出IO


UPDATE:
	MOV A, MEM_H
	MOV B, #0x0A
	DIV AB							;A is high, B is low
	RL A
	RL A
	RL A
	RL A
	ADD A, B
	MOV IO_H, A

	MOV A, MEM_M
	MOV B, #0x0A
	DIV AB	
	RL A
	RL A
	RL A
	RL A
	ADD A, B
	MOV IO_M, A

	MOV A, MEM_S
	MOV B, #0x0A
	DIV AB	
	RL A
	RL A
	RL A
	RL A
	ADD A, B
	MOV IO_S, A

	RETI
	

到目前,我们已经完成了时分秒的计算与调整时间的输入处理。但是,时分秒仍然在内存中,且时间仍然以时、分、秒为单位储存,并未切割到时、分、秒各自的十位与个位。

我们可以通过整除10的方式要获取十位与个位,商为十位,余数为个位。具体操作:我们将除数(时间)与被除数(10)载入累加器A与B,并执行DIV指令。

此时,十位数就在累加器A里面,个位数就在累加器B里面。因为十位在IO的高位4比特,个位在IO的低位4比特,我们将累加器A里面的十位数左移4位后和累加器B里面的个位数相加,就可以得到BCD编码的时间并输出了。

任务结束,使用RETI指令结束中断,返回主程序的忙等待。

最终成品

成品照片
成品照片
成品照片
成品照片
暗中发光
暗中发光
暗中发光
暗中发光