数组迭代循环中使用下标和指针的区别

这篇博客探讨了在C语言中,使用数组下标和指针遍历数组时的效率差异。通过两组例子和对应的汇编代码分析,得出结论:在固定增量遍历时,指针可能更高效;寄存器指针比静态或堆栈指针效率更高;避免在运行时计算常量表达式。然而,考虑到可读性,通常以简洁和易懂为主,除非性能提升显著。

我们来看一下2组例子:

第一组例子:

下标表达式:

a = get_value();
array[a] = 0;

指针表达式:

a = get_value();
*(array + a) = 0;

第二组例子:

下标表达式:

int array[10], a;
for(a=0; a<10; a+=1) {
    array[a] = 0;
}

指针表达式:

int array[10], *ap;
for(a=array; a < array + 10; ap++) {
    *ap = 0;
}

这2组例子都是一个使用下标,一个使用指针,每组例子的2中方式都实现相同的效果,但这2组例子使用下标和指针访问数组元素效率是一样的吗?

第一组例子效率完全一致,第二组例子循环体所有区别,计数器1和整形长度相乘,然后和指针相加,每次执行乘法迅运算都是相同的1和4。而指针每次都是相同的1和4相乘,而且只是在编译时执行一次,后面就是把指针和4相加。

它们到底怎么工作的呢?接下来,我们就来研究一下:

一、先看一个下标版本

#include <stdio.h>

#define SIZE 50
int x[SIZE];
int y[SIZE];
int i;
int *p1, *p2;

void try1() {
    for(i = 0; i < SIZE; i++) {
        x[i] = y[i];
    }
}


int main () {
    try1();
    return 0;
}

编译获得汇编代码:

	.file	"main.c"
	.text
	.globl	x
	.bss
	.align 32
	.type	x, @object
	.size	x, 200
x:
	.zero	200
	.globl	y
	.align 32
	.type	y, @object
	.size	y, 200
y:
	.zero	200
	.globl	i
	.align 4
	.type	i, @object
	.size	i, 4
i:
	.zero	4
	.globl	p1
	.align 8
	.type	p1, @object
	.size	p1, 8
p1:
	.zero	8
	.globl	p2
	.align 8
	.type	p2, @object
	.size	p2, 8
p2:
	.zero	8
	.text
	.globl	try1
	.type	try1, @function
try1:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$0, i(%rip)
	jmp	.L2
.L3:
	movl	i(%rip), %eax
	movl	i(%rip), %ecx
	cltq
	movl	y(,%rax,4), %edx
	movslq	%ecx, %rax
	movl	%edx, x(,%rax,4)
	movl	i(%rip), %eax
	addl	$1, %eax
	movl	%eax, i(%rip)
.L2:
	movl	i(%rip), %eax
	cmpl	$49, %eax
	jle	.L3
	nop
	nop
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	try1, .-try1
	.globl	main
	.type	main, @function
main:
.LFB1:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$0, %eax
	call	try1
	movl	$0, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE1:
	.size	main, .-main
	.ident	"GCC: (GNU) 10.2.1 20201016 (Red Hat 10.2.1-6)"
	.section	.note.GNU-stack,"",@progbits

我们只看try1这个函数的汇编实现:

编译器做了一些优化,这里.LFB0对try1函数做了初始化,然后跳到循环.L2,L2里面判断for循环条件,如果没超过49就执行循环体L3,所以这里的循环体就是我们要看的主要部分:

	movl	i(%rip), %eax
	movl	i(%rip), %ecx
	cltq
	movl	y(,%rax,4), %edx
	movslq	%ecx, %rax
	movl	%edx, x(,%rax,4)
	movl	i(%rip), %eax
	addl	$1, %eax
	movl	%eax, i(%rip)

循环体就一个x[i] = y[i]的赋值语句,对应的汇编代码:

# main.c:11:         x[i] = y[i];
	movl	i(%rip), %eax	# i, i.0_1
# main.c:11:         x[i] = y[i];
	movl	i(%rip), %ecx	# i, i.1_2
# main.c:11:         x[i] = y[i];
	cltq
	movl	y(,%rax,4), %edx	# y[i.0_1], _3
# main.c:11:         x[i] = y[i];
	movslq	%ecx, %rax	# i.1_2, tmp89
	movl	%edx, x(,%rax,4)	# _3, x[i.1_2]

将i读入eax寄存器(累积暂存器),将i读入ecx寄存器(计数寄存器),  cltq 等效于 movslq%eax,%rax,就是将eax的地址扩展后读入rax寄存器,然后将y的新地址读入到edx寄存器,接下来扩展ecx的地址,读入到rax寄存器,然后将edx寄存器读入到x的新地址。 这里我们看到有2次乘以4的操作,因为这里是整形,地址占4个字节。

二、指针版本

void try2() {
    for(p1 = x, p2 = y; p1 - x < SIZE; ) {
        *p1++ = *p2++;
    }
}

编译得到汇编:

try2:
.LFB1:
	.cfi_startproc
	pushq	%rbp	#
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp	#,
	.cfi_def_cfa_register 6
# main.c:16:     for(p1 = x, p2 = y; p1 - x < SIZE; ) {
	movq	$x, p1(%rip)	#, p1
# main.c:16:     for(p1 = x, p2 = y; p1 - x < SIZE; ) {
	movq	$y, p2(%rip)	#, p2
# main.c:16:     for(p1 = x, p2 = y; p1 - x < SIZE; ) {
	jmp	.L5	#
.L6:
# main.c:17:         *p1++ = *p2++;
	movq	p2(%rip), %rdx	# p2, p2.4_1
	leaq	4(%rdx), %rax	#, _3
	movq	%rax, p2(%rip)	# _3, p2
# main.c:17:         *p1++ = *p2++;
	movq	p1(%rip), %rax	# p1, p1.6_4
	leaq	4(%rax), %rcx	#, _6
	movq	%rcx, p1(%rip)	# _6, p1
# main.c:17:         *p1++ = *p2++;
	movl	(%rdx), %edx	# *p2.5_2, _7
# main.c:17:         *p1++ = *p2++;
	movl	%edx, (%rax)	# _7, *p1.7_5
.L5:
# main.c:16:     for(p1 = x, p2 = y; p1 - x < SIZE; ) {
	movq	p1(%rip), %rax	# p1, p1.8_8
	subq	$x, %rax	#, _9
# main.c:16:     for(p1 = x, p2 = y; p1 - x < SIZE; ) {
	cmpq	$196, %rax	#, _9
	jle	.L6	#,
# main.c:19: }
	nop	
	nop	
	popq	%rbp	#
	.cfi_def_cfa 7, 8
	ret	
	.cfi_endproc

这里L5是循环结束控制,使用了地址的方式,整形地址占4个字节,50个整数,就是200个字节,所以0-196个字节表示循环体范围。

L6就是循环体代码中分别读取p2和p1的地址,读取p2的值给p1,这里同样有2次乘以4的地址转换,效率看起来没啥变化。而且L5里面的循环条件更复杂一点。

 

三、指针版本+计数器版本

void try3() {
    for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
        *p1++ = *p2++;
    }
}

编译得到汇编代码:

try3:
.LFB2:
	.cfi_startproc
	pushq	%rbp	#
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp	#,
	.cfi_def_cfa_register 6
# main.c:22:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	movl	$0, i(%rip)	#, i
# main.c:22:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	movq	$x, p1(%rip)	#, p1
# main.c:22:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	movq	$y, p2(%rip)	#, p2
# main.c:22:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	jmp	.L8	#
.L9:
# main.c:23:         *p1++ = *p2++;
	movq	p2(%rip), %rdx	# p2, p2.9_1
	leaq	4(%rdx), %rax	#, _3
	movq	%rax, p2(%rip)	# _3, p2
# main.c:23:         *p1++ = *p2++;
	movq	p1(%rip), %rax	# p1, p1.11_4
	leaq	4(%rax), %rcx	#, _6
	movq	%rcx, p1(%rip)	# _6, p1
# main.c:23:         *p1++ = *p2++;
	movl	(%rdx), %edx	# *p2.10_2, _7
# main.c:23:         *p1++ = *p2++;
	movl	%edx, (%rax)	# _7, *p1.12_5
# main.c:22:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	movl	i(%rip), %eax	# i, i.13_8
	addl	$1, %eax	#, _9
	movl	%eax, i(%rip)	# _9, i
.L8:
# main.c:22:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	movl	i(%rip), %eax	# i, i.14_10
# main.c:22:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	cmpl	$49, %eax	#, i.14_10
	jle	.L9	#,
# main.c:25: }
	nop	
	nop	
	popq	%rbp	#
	.cfi_def_cfa 7, 8
	ret	
	.cfi_endproc

使用计数器的方式,简化了循环条件的判断处理,虽然C代码看起来冗余一些,但是汇编效率可能好一丢丢......

但总体差别并不大,都会有复制指针值的

四、寄存器指针版本

void try4() {
    register int *p1, *p2;
    register int i;
    for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
        *p1++ = *p2++;
    }
}

编译得到汇编代码:

try4:
.LFB3:
	.cfi_startproc
	pushq	%rbp	#
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp	#,
	.cfi_def_cfa_register 6
	pushq	%r13	#
	pushq	%r12	#
	pushq	%rbx	#
	.cfi_offset 13, -24
	.cfi_offset 12, -32
	.cfi_offset 3, -40
# main.c:31:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	movl	$0, %ebx	#, i
# main.c:31:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	movl	$x, %r12d	#, p1
# main.c:31:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	movl	$y, %r13d	#, p2
# main.c:31:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	jmp	.L11	#
.L12:
# main.c:32:         *p1++ = *p2++;
	movq	%r13, %rdx	# p2, p2.15_1
	leaq	4(%rdx), %r13	#, p2
# main.c:32:         *p1++ = *p2++;
	movq	%r12, %rax	# p1, p1.16_2
	leaq	4(%rax), %r12	#, p1
# main.c:32:         *p1++ = *p2++;
	movl	(%rdx), %edx	# *p2.15_1, _3
# main.c:32:         *p1++ = *p2++;
	movl	%edx, (%rax)	# _3, *p1.16_2
# main.c:31:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	addl	$1, %ebx	#, i
.L11:
# main.c:31:     for(i = 0, p1 = x, p2 = y; i < SIZE; i++) {
	cmpl	$49, %ebx	#, i
	jle	.L12	#,
# main.c:34: }
	nop	
	nop	
	popq	%rbx	#
	popq	%r12	#
	popq	%r13	#
	popq	%rbp	#
	.cfi_def_cfa 7, 8
	ret	
	.cfi_endproc
.LFE3:
	.size	try4, .-try4
	.globl	main
	.type	main, @function

这里的循环体L12就高效一些了,因为指针变量直接保存在寄存器中的,直接使用硬件的地址自增模型直接增加值。

四、去掉计数器的纯寄存器指针版本

void try5() {
    register int *p1, *p2;
    for(p1 = x, p2 = y; p1 < &x[SIZE];) {
        *p1++ = *p2++;
    }
}

编译得到汇编代码:

try5:
.LFB4:
	.cfi_startproc
	pushq	%rbp	#
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp	#,
	.cfi_def_cfa_register 6
	pushq	%r12	#
	pushq	%rbx	#
	.cfi_offset 12, -24
	.cfi_offset 3, -32
# main.c:38:     for(p1 = x, p2 = y; p1 < &x[SIZE];) {
	movl	$x, %ebx	#, p1
# main.c:38:     for(p1 = x, p2 = y; p1 < &x[SIZE];) {
	movl	$y, %r12d	#, p2
# main.c:38:     for(p1 = x, p2 = y; p1 < &x[SIZE];) {
	jmp	.L14	#
.L15:
# main.c:39:         *p1++ = *p2++;
	movq	%r12, %rdx	# p2, p2.17_1
	leaq	4(%rdx), %r12	#, p2
# main.c:39:         *p1++ = *p2++;
	movq	%rbx, %rax	# p1, p1.18_2
	leaq	4(%rax), %rbx	#, p1
# main.c:39:         *p1++ = *p2++;
	movl	(%rdx), %edx	# *p2.17_1, _3
# main.c:39:         *p1++ = *p2++;
	movl	%edx, (%rax)	# _3, *p1.18_2
.L14:
# main.c:38:     for(p1 = x, p2 = y; p1 < &x[SIZE];) {
	cmpq	$x+200, %rbx	#, p1
	jb	.L15	#,
# main.c:41: }
	nop	
	nop	
	popq	%rbx	#
	popq	%r12	#
	popq	%rbp	#
	.cfi_def_cfa 7, 8
	ret	
	.cfi_endproc

这个版本C代码就很简洁,汇编代码也很高效,$x+200就是&x[SIZE], 由于SIZE是个常量,编译过程就会完成,这就是最好的实现。

结论:

1、当根据某个固定数目的增量在一个数组中移动时,使用指针变量将比使用下标产生效率更高的代码。

2、声明寄存器变量的指针通常比位于静态内存和堆栈中的指针效率更高。

3、如果可以通过测试一些已经初始化并经过调整的内容来判断循环是否应该终止,就不需要计数器帮助。

4、在运行时求值比&array[SIZE]这种常量表达式代价高。

 

但面向C程序员的,可读性很重要,所以在效率提高不很明显的情况下,以简洁可读性高为原则,越复杂风险越高。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值