今天听到隔壁同事们在讨论循环效率的问题,大概是在争论 while(1) 和 for(;;) 循环哪种效率更高。有支持 while(1) 的,有支持 for(;;) 的,并且两方的观点都有一定的道理。
诚然,在嵌入式开发尤其是单片机开发中,很多逻辑的运行往往都有最短时间周期的限制,并且在一些控制系统中这些限制都是微秒级别的,如电机的闭环,某些传感器的采样等等,这就导致工程师不得不绞尽脑汁去优化代码,控制运行时间,不放过几个毫秒,甚至几个微秒的时间延迟。
虽然我并没有加入到他们的讨论中,但我个人也很感兴趣,我想知道在我的测试平台上(STM32F103、ARMCC5),到底是 while(1) 更快,还是 for(;;) 更快,又是为什么?
于是我立马新建了一个空的工程,并且写了一个非常简单的测试代码,两者同样都是让变量 cnt 自增 100000 次,达到指定次数后退出逻辑,对比两者的运行速度。
#define MAX_CNT 100000
void test_func(void) {
#if 1
while (1) {
cnt++;
if (cnt > MAX_CNT) break;
}
#else
for (;;) {
cnt++;
if (cnt > MAX_CNT) break;
}
#endif
}
通过代码可以看出两者的循环体完全相同,仅仅是使用的循环结构不同。
上述函数会在 main 函数中被调用,并且在调用该函数的语句前后还会加入 GPIO 的翻转,从而借助逻辑分析仪查看两者的运行时间:
int main(void)
{
// ... 省略
HAL_GPIO_WritePin(test_GPIO_Port, test_Pin, GPIO_PIN_SET);
test_func();
HAL_GPIO_WritePin(test_GPIO_Port, test_Pin, GPIO_PIN_RESET);
// ... 省略
}
我们先将两个逻辑分别运行一下(不开编译器优化),查看逻辑分析仪输出的结果。
while(1) 逻辑运行结果:
for(;;) 逻辑运行结果:
虽然循环体完全相同,逻辑上两者是完全一样的,但从实际运行结果来看,确实在运行速度上会有所差别,并且 for(;;) 语句执行得更快(45.863ms),从数据上来看比 while(1)(48.643ms) 快了 5.7% 左右。
究竟是什么导致了这种差异的存在?
我们来拨开两者真面目 —— 汇编代码。
while(1) 逻辑汇编代码:
0x08001030: e00a .. B 0x8001048 ; test_func + 24
0x08001032: 4807 .H LDR r0,[pc,#28] ; [0x8001050] = 0x20000000
0x08001034: 6800 .h LDR r0,[r0,#0]
0x08001036: 1c40 @. ADDS r0,r0,#1
0x08001038: 4905 .I LDR r1,[pc,#20] ; [0x8001050] = 0x20000000
0x0800103a: 6008 .` STR r0,[r1,#0]
0x0800103c: 4608 .F MOV r0,r1
0x0800103e: 6800 .h LDR r0,[r0,#0]
0x08001040: 4904 .I LDR r1,[pc,#16] ; [0x8001054] = 0x186a0
0x08001042: 4288 .B CMP r0,r1
0x08001044: d900 .. BLS 0x8001048 ; test_func + 24
0x08001046: e000 .. B 0x800104a ; test_func + 26
0x08001048: e7f3 .. B 0x8001032 ; test_func + 2
0x0800104a: bf00 .. NOP
0x0800104c: 4770 pG BX lr
for(;;) 逻辑汇编代码:
0x08001030: bf00 .. NOP
0x08001032: 4806 .H LDR r0,[pc,#24] ; [0x800104c] = 0x20000000
0x08001034: 6800 .h LDR r0,[r0,#0]
0x08001036: 1c40 @. ADDS r0,r0,#1
0x08001038: 4904 .I LDR r1,[pc,#16] ; [0x800104c] = 0x20000000
0x0800103a: 6008 .` STR r0,[r1,#0]
0x0800103c: 4608 .F MOV r0,r1
0x0800103e: 6800 .h LDR r0,[r0,#0]
0x08001040: 4903 .I LDR r1,[pc,#12] ; [0x8001050] = 0x186a0
0x08001042: 4288 .B CMP r0,r1
0x08001044: d9f5 .. BLS 0x8001032 ; test_func + 2
0x08001046: bf00 .. NOP
0x08001048: bf00 .. NOP
0x0800104a: 4770 pG BX lr
先不看逻辑,仅从代码行数上看,while(1) 被编译成了 15 条汇编代码,而 for(;;) 被编译成了 14 条汇编代码,仅从代码行数上来看,for 就领先了一步,接下来我们详细分析下两者的汇编逻辑。
既然 for 更领先一步,那我们就先来扒一扒 for 的逻辑:
NOP ;空指令
LDR r0,[pc,#24] ;--------------------------------------
LDR r0,[r0,#0] ;这五句实现了 cnt ++ 的功能
ADDS r0,r0,#1 ;简单来说就是:
LDR r1,[pc,#16] ;取出cnt原始值,将值加1,将加1后的值放回cnt中
STR r0,[r1,#0] ;---------------------------------------
MOV r0,r1 ;定位到 cnt 的地址
LDR r0,[r0,#0] ;从 cnt 中取出值
LDR r1,[pc,#12] ;取出 MAX_CNT 的值
CMP r0,r1 ;将 cnt 的值和 MAX_CNT 的值作比较
BLS 0x8001032 ;如果 cnt<MAX_CNT 则跳转到 0x8001032 处,也就是上面的第二行代码处,实现循环。否则继续往下执行。
NOP ;空指令
NOP ;空指令
BX lr ;退出当前函数
可以看到核心逻辑分为两部分,第 2 - 6 这五行是实现了将 cnt 变量自加的操作,第 7 - 11 行 则实现了将自加后的 cnt 变量与我们定义的 MAX_CNT 做对比,根据比较结果来确定后续是继续自加还是退出循环。
我们再来看下 while 逻辑:
B 0x8001048 ;跳转到0x8001048处运行(倒数第三行)
LDR r0,[pc,#28] ;--------------------------------------
LDR r0,[r0,#0] ;这五句实现了 cnt ++ 的功能
ADDS r0,r0,#1 ;简单来说就是:
LDR r1,[pc,#20] ;取出cnt原始值,将值加1,将加1后的值放回cnt中
STR r0,[r1,#0] ;--------------------------------------
MOV r0,r1 ;定位到 cnt 的地址
LDR r0,[r0,#0] ;从 cnt 中取出值
LDR r1,[pc,#16] ;取出 MAX_CNT 的值
CMP r0,r1 ;将 cnt 的值和 MAX_CNT 的值作比较
BLS 0x8001048 ;如果 cnt<MAX_CNT 则跳转到 0x8001048 (倒数第三行)
B 0x800104a ;跳转到 0x800104a 处运行 (倒数第二行)
B 0x8001032 ;跳转到0x8001032处运行 (第二行)
NOP ;空指令
BX lr ;退出当前函数
核心部分与 for 逻辑完全相同,而在循环控制逻辑处我们发现了一点端倪!接下来我们去掉核心部分,也就是 C 代码中的循环体部分,将循环控制逻辑单独拿出来对比,为了便于理解,我们直接用流程图的形式来呈现。
很明显,除了绿色的相同部分,while 语句比 for 语句多出了很多的跳转指令,而且基本都是间接跳转,也就是一次跳转不能直接跳到实际要执行的逻辑上,而是跳到一个中间逻辑,再通过这个中间逻辑跳转到实际要执行的指令。并且这种间接跳转存在于每一次循环!
我们着重关注 cnt<MAX_CNT 这里的逻辑,for 语句中只要这个判断成立,就会跳转到循环的开头继续循环体,而 while 语句却需要跳到一个中间指令 a,经过这个 a 指令的运行,才能回到循环的开头。每一次循环就会多执行这一步,必然也就导致了其运行速度的下降。
到这里,我们终于找到了两个逻辑上完全相同的循环其运行速度却不同的根本原因 —— 编译器编译出的机器指令不同,for 的指令更精简,而 while 的指令相对更繁琐,简而言之 for 抄了近道,而 while 弯弯绕绕,谁最先能到终点,结果显而易见。
实际上,这里的结果完全是取决于编译器,在我的测试中使用的是 ARMCC V5,结果如上所示,但是换一个编译器可能结果就会截然相反。甚至同一个编译器,开不开优化也会影响最终的结果,上文中的结论是不开优化,一旦我开启O3优化,结果就不一样了:
可以看到,两者已经几乎没有差别(12.505ms)(这里的0.00001ms可认为是测量误差)。同样我们再对比一下汇编代码:
for 优化后
0x08000c30: 4903 .I LDR r1,[pc,#12] ; [0x8000c40] = 0x20000000
0x08000c32: 4a04 .J LDR r2,[pc,#16] ; [0x8000c44] = 0x186a0
0x08000c34: 6808 .h LDR r0,[r1,#0]
0x08000c36: 1c40 @. ADDS r0,r0,#1
0x08000c38: 4290 .B CMP r0,r2
0x08000c3a: d9fc .. BLS 0x8000c36 ; test_func + 6
0x08000c3c: 6008 .` STR r0,[r1,#0]
0x08000c3e: 4770 pG BX lr
while 优化后
0x08000c30: 4903 .I LDR r1,[pc,#12] ; [0x8000c40] = 0x20000000
0x08000c32: 4a04 .J LDR r2,[pc,#16] ; [0x8000c44] = 0x186a0
0x08000c34: 6808 .h LDR r0,[r1,#0]
0x08000c36: 1c40 @. ADDS r0,r0,#1
0x08000c38: 4290 .B CMP r0,r2
0x08000c3a: d9fc .. BLS 0x8000c36 ; test_func + 6
0x08000c3c: 6008 .` STR r0,[r1,#0]
0x08000c3e: 4770 pG BX lr
很明显经过编译器的优化后两种语句生成的汇编代码已经没有任何区别,自然在运行速度上也就不分伯仲了。
因此抛开环境谈性能,尤其是一味纠结于语句本身的优劣是毫无意义的,存在即合理,一个事物既然没有被抛弃,说明其仍然具有一定的优势,至少在某个方面有出众的表现,这里的两个语句也是一样,在我的测试环境下不开优化 for 更快,开了优化一样快,但换一个编译器,或者换一种循环体,while 就有可能占领优势。甚至可以说在大部分使用场景下为了追求这么一点的性能提升而抠语句是完全没有必要的。
当然,正如开头所说的,在某些要求非常严苛的应用场景,我们确实有必要做一些极致的优化,哪怕是一毫秒的提升,对于微秒级的场景来说,也具有很大的优势。此时你就需要针对于你的运行环境去进行特别处理。当然,前提是你掌握了分析与处理的技巧。
最后,本文的测试中 while 与 for 到底谁更快已经很明显,但并不重要,也不具备太大的参考价值。重点在于造成其运行效果差异背后的本质与原理,掌握分析其本质的技能,才是这篇文章能提供的最大收益。