在 硬件 API 长什么样儿?一文中我们使用了延时函数来保证硬件 API 要求遵守的时序协议。示例:
void write_zero()
{
DS18B20_DQ = 0;
delay_60us();
DS18B20_DQ = 1;
// 总线恢复时间
delay_2us();
}
代码中的 delay_2us() 和 delay_60us() 都是延时函数并且它们属于软延时,用软件模拟的延时。与之相对的是硬延时,即用定时器等硬件来更精确地设置延时。
软延时的原理就是让 CPU 执行一些指令,并且执行这些指令所用的时间等于想要延时的时间,同时执行这些指令不应该影响程序的结果。例如,可以使用空循环:
for (i = 0; i < 1000; i++) {
// noop
}
现在,问题转变为如何计算指令执行时间,比如执行上例的循环到底需要多长时间?要回答这个问题需要弄清楚 CPU 时序,明白 CPU 是怎么执行指令的。下文使用 8051 单片机举例,因为它的实现特别简单。
CPU 振荡周期(时钟周期)
驱动 CPU 不停地自动执行下一条指令的关键部件是“时钟”。时钟不间断地产生高低电平的脉冲信号来驱动电路。如下图所示:
晶体振荡器(简称晶振)使用了特殊的石英晶体元件,它在特定条件下能够产生稳定的周期性振荡信号,经过特殊处理后可以用作时钟。晶振可以长下面这个样子:
时钟频率是时钟的一项关键指标。它表示 1 秒内时钟脉冲周期性变化的次数,单位为赫兹 Hz。时钟频率反映了脉冲信号周期性变化的快慢,频率越高振荡速度越快。它也在一定程序上决定了 CPU 速度的快慢。
拿上图中的晶振举例,上面的 12.000 表示频率为 12MHz,即 1 秒钟完成 12, 000, 000 个振荡周期。因此,1 个振荡周期用时为 (1秒 / 12, 000, 000),即 1/12 微秒 (μs)。
CPU 机器周期(machine cycle)
机器周期用于衡量计算机指令的执行时间。例如,执行指令 A 需要 1 个机器周期、执行指令 B 需要 2 个机器周期等等。在 51 单片机中,机器周期与振荡周期的关系为(假设使用了 12MHz 晶振):
接下来,我们需要知道每条指令的执行需要多少个机器周期。方法是查询芯片厂商的技术文档,因为这是由指令的详细设计和实现所决定的。
示例1,NOP 空操作指令,需要 1 机器周期,即 1 微秒:
示例2,DIV 除法指令,需要 4 机器周期,即 4 微秒:
开始编程
延时 1 微秒:
// 注意,这是一个特殊的库函数!它不表示函数调用!!
// 编译器会将其编译为一个 NOP 汇编指令
_nop_();
延时 2 微秒:
// 注意,这是一个特殊的库函数!它不表示函数调用!!
// 编译器会将其编译为一个 NOP 汇编指令
_nop_();
_nop_();
延时 4 微秒:
void delay_4us()
{
// no-op
}
delay_4us();
解释:这里我们定义了一个空函数 delay_4us(),然后直接调用这个空函数就相当于延时 4 微秒。因为,调用函数使用 LCALL 汇编指令,需要 2 个机器周期;从函数返回使用 RET 汇编指令,需要 2 个机器周期;一共 4 个机器周期,即 4 微秒。
延时 5 微秒:
void delay_5us()
{
_nop_();
}
delay_5us();
延时 100 微秒:
void delay_100us()
{
unsigned char i;
_nop_();
i = 47;
while (--i);
}
delay_100us();
解释:此例又增加使用了 MOV 和 减 1 非零转移指令。如果延时较长,则需要使用某种形式的循环,不可能拷贝 N 个 NOP 指令的。
总结
指令和周期还有很多技术细节本文并没有描述。软延时不总是可靠的,原因之一是因为中断系统的存在。在软延时空跑指令的时候,有可能中断被触发,这时 CPU 转而去处理中断请求;等中断处理完毕,CPU 才会回到之前的延时程序,此刻的延时时间早就过去了。因此,更准确的延时/定时功能需要使用定时器中断功能。