数组语法 vs 指针语法和代码生成?

55

在书籍"Understanding and Using C Pointers"(作者:Richard Reese)的第85页中提到:

int vector[5] = {1, 2, 3, 4, 5};

The code generated by vector[i] is different from the code generated by *(vector+i) . The notation vector[i] generates machine code that starts at location vector , moves i positions from this location, and uses its content. The notation *(vector+i) generates machine code that starts at location vector , adds i to the address, and then uses the contents at that address. While the result is the same, the generated machine code is different. This difference is rarely of significance to most programmers.

你可以在这里查看摘录。这一段是什么意思?在什么情况下会有编译器为这两个生成不同的代码?从基地址移动和添加到基地址之间有什么区别吗?我无法在GCC上实现这个,它生成了不同的机器代码。

16
对于大多数现代编译器来说,“生成的机器码不同”可能是错误的。 - JimmyB
2
对我来说,“将i添加到地址”意味着移动i个字节。但实际发生的是它会添加i * sizeof(&vector[0])个字节。正如其他人指出的那样,C标准将a[i]声明为*(a + i),因此这段话只是纯粹令人困惑的。 - Goswin von Brederlow
2
@KonradRudolph,我有很多关于指针的问题,这些问题来自书籍和已发布的博客文章,如果你想要毁掉你的一周的话。https://dev59.com/Y6zla4cB1Zd3GeqPBLn_ 还有更多问题在等着呢。 - Evan Carroll
如果下标是一个常量表达式(例如a[5]),编译器将生成不同的代码,因为它可以在编译时计算偏移量。但是,如果不知道翻译时i的值,我不明白a[i]*(a+i)会有什么不同的处理方式。 - John Bode
@Barmar:“我不明白如果不知道编译时i的值,为什么a[i]*(a + i)会有不同的处理方式” - 这样更好吗?我并没有将a[5]*(a + i)进行比较。 - John Bode
显示剩余6条评论
8个回答

98

这个引用是错误的。如此垃圾的东西仍然在本十年内出版,实在是很悲哀。实际上,C标准定义了x[y]*(x+y)

页面后面关于lvalue的部分也完全错误。

在我看来,使用这本书的最佳方式就是将它放入回收箱或者烧掉。


3
我不会说这是“错误的”,但它并不完整。事实上,有些编译器生成 x[y]*(x+y) 的机器码可能真的不同(其实,也是一样的情况出现在 *(y+x)y[x] 上)。换句话说,如果我们在整个引用前加上“在某些编译器上...”,那么它就是正确的。 - srdjan.veljkovic
3
日常使用方法是将这本书作为杯热咖啡的架子。 - red0ct
1
@srdjan.veljkovic 如果你用“在某些编译器上”来限定,几乎任何事情都是可能的。它可能会根据月相生成不同的代码。如果这本书说“机器码可能会有所不同”,那就不是什么大问题了。 - Barmar
5
如果一个联合体包含一个数组,在访问theUnion.anArray[i]*(theUnion.anArray+i)时gcc会生成不同的机器码。只有在前一种情况下,gcc才会足够聪明地识别到对anArray[i]的访问可能会影响联合体及其其他成员。 - supercat
2
GCC使用一种代码生成/执行模型,将形式为aggregate.memberaggregate.member[index]aggregate.member[index1][index2]等的lvalue表达式视为对聚合体的操作,但无法识别指向聚合体成员的指针与聚合体之间的任何关系,即使在指针被立即使用的情况下也是如此。标准将支持这些构造的方式视为实现质量问题,而gcc是围绕标准允许低质量实现的事实而设计的。 - supercat
显示剩余8条评论

33

我有两个C文件:ex1.c

% cat ex1.c
#include <stdio.h>

int main (void) {
    int vector[5] = { 1, 2, 3, 4, 5 };
    printf("%d\n", vector[3]);
}

ex2.c

% cat ex2.c
#include <stdio.h>

int main (void) {
    int vector[5] = { 1, 2, 3, 4, 5 };
    printf("%d\n", *(vector + 3));
}

然后我将两者编译成汇编语言,并展示生成的汇编代码之间的差异。

% gcc -S ex1.c; gcc -S ex2.c; diff -u ex1.s ex2.s
--- ex1.s       2018-07-17 08:19:25.425826813 +0300
+++ ex2.s       2018-07-17 08:19:25.441826756 +0300
@@ -1,4 +1,4 @@
-       .file   "ex1.c"
+       .file   "ex2.c"
        .text
        .section        .rodata
 .LC0:

Q.E.D.


C标准非常明确地规定(C11 n1570 6.5.2.1p2):

  1. 后缀表达式加上方括号中的表达式[]是数组对象元素的下标指示符。 下标运算符[]的定义是E1[E2]等价于(*((E1)+(E2)))。由于适用于二进制+运算符的转换规则,如果E1是一个数组对象(或者等效地,是一个数组对象的初始元素的指针),并且E2是一个整数,则E1[E2]指定E1的第E2个元素(从零开始计数)。

此外,如同法则也适用于这里-如果程序的行为相同,则即使语义不同,编译器也可以生成相同的代码。


4
这种做法基于特定编译器和优化的假设,但通常与我所做的一样。然而,我并不满意,因为这种测试是基于体系结构字节码对语言进行假设的。 - Evan Carroll
2
有趣的一点是:根据引用和实践,i[vector]也可以工作,尽管在大多数情况下这样做会很糟糕。你不能从位置i开始并移动vector个位置。 - Daniel H
3
我不满意,因为这种测试基于某种体系结构的字节码对语言进行了假设。关键在于我们正在讨论生成的汇编代码,这与实施特定密切相关。除了注意标准所规定的可观察行为的等效性之外,你可以对这种说法做的唯一一件事就是查看各种编译器的输出。 - Matteo Italia
@DanielH - 它确实有效,而且如果我没记错的话,这种语法在“国际混淆C代码大赛”中出现了几次。(有时是以 'c'[someptr] 或更糟糕的形式出现。) 我有一个模糊的记忆,曾经提交了一段代码 n["0123456789ABCDEF"],主要是为了搞乱正在进行代码审查的朋友... 回想起来,我不应该为此感到自豪... 我只希望能记得他的反应是什么... - davidbak
@davidbak 是的,它可以,但是原帖中的书籍描述似乎暗示它不能。而且,你知道的,无论如何都不应该这样做。 - Daniel H
@AnttiHaapala 为了完整起见,我刚刚使用 MSVC 进行了类似的测试,它也为两种形式生成了完全相同的代码:movsxd rax,dword ptr [i] mov edx,dword ptr vector[rax * 4] - dgnuff

19
引用的段落是错误的。表达式vector[i]*(vector+i)是完全相同的,可以期望在所有情况下生成相同的代码。 vector[i]*(vector+i)是根据定义相同的。这是C编程语言的一个核心和基本属性。任何有能力的C程序员都明白这一点。任何一本名为《理解和使用C指针》的书的作者都必须明白这一点。任何一位C编译器的作者都会明白这一点。两个片段将生成相同的代码,不是偶然,而是因为几乎任何C编译器都会立即将一种形式转换为另一种形式,以至于在进入代码生成阶段时,它甚至不知道最初使用了哪种形式。(如果C编译器为vector[i]*(vector+i)生成显著不同的代码,我会非常惊讶。)
实际上,引用的文本自相矛盾。正如您所指出的那样,两个段落
vector[i]符号表示生成从位置vector开始的机器代码,从该位置移动 i 个位置,并使用其内容。”

*(vector+i)符号表示生成从位置vector开始的机器代码,将 i 添加到地址中,然后使用该地址处的内容。”
基本上说的是同样的事情。
他的说法与旧版C FAQ list中的question 6.2非常相似:
当编译器看到表达式a[3]时,它会发出代码从位置“a”开始,向后移动三个位置,并提取那里的字符。当它看到表达式p[3]时,它会发出代码从位置“p”开始,提取指针值,将其加上三个位置,最后提取指向的字符。
但当然,这里的关键区别在于a是一个数组,而p是一个指针。FAQ列表讨论的不是a[3]*(a+3)之间的区别,而是a是数组时的a[3](或*(a+3))与p是指针时的p[3](或*(p+3))之间的区别。(当然,这两种情况生成不同的代码,因为数组和指针是不同的。正如FAQ列表所解释的那样,从指针变量中获取地址与使用数组的地址基本上是不同的。)

1
你提到了旧的C FAQ,这是一个非常好的发现。但是即使在那种情况下,你也没有谈论它们为什么不同:你只是说(当然,这两种情况生成不同的代码,因为数组和指针是不同的)。也许需要一些解释。 - Evan Carroll
标准可能将表达式视为等效,但许多编译器仅将其解释为建议在标准定义了一个行为的情况下,它定义了两者的行为。标准对于访问联合中非字符数组元素(或任何非字符联合成员)的行为不会施加任何要求,但如果这些数组不像其他成员一样展现出类型转换行为,则它们将变得相当无用。GCC将以这种方式处理someUnion.array[i],但不会对*(someUnion.arr+i)这样做。 - supercat
1
“完全相同,可以预期在所有情况下生成相同的代码” - 这不是标准所说的。定义涉及语义,因此在两种情况下,您肯定会得到相同的数组内容。 如何实现取决于编译器,可能会因编译器、平台和优化级别而有很大不同。 - JimmyB

6
我认为原文可能是指一些编译器可能会执行或不执行的优化操作。
例如:
for ( int i = 0; i < 5; i++ ) {
  vector[i] = something;
}

对比。

for ( int i = 0; i < 5; i++ ) {
  *(vector+i) = something;
}

在第一种情况下,优化编译器可能会检测到数组vector被逐个元素迭代,因此生成类似以下代码:
void* tempPtr = vector;
for ( int i = 0; i < 5; i++ ) {
  *((int*)tempPtr) = something;
  tempPtr += sizeof(int); // _move_ the pointer; simple addition of a constant.
}

它甚至可以在可用的情况下使用目标CPU的指针后增指令。
对于第二种情况,编译器更难看到通过一些“任意”指针算术表达式计算出的地址显示出每次迭代单调地向前移动固定量的属性。因此,它可能无法找到优化并在每次迭代中计算((void*)vector+i*sizeof(int)),这会使用额外的乘法。在这种情况下,没有(临时)指针被“移动”,只有一个临时地址被重新计算。
但是,该语句可能并不普遍适用于所有C编译器的所有版本。
更新:
我检查了上面的例子。 似乎没有启用优化时,至少gcc-8.1 x86-64为第二个(指针算术)形式生成更多代码(2个额外指令)而不是第一个(数组索引)。
请参见:https://godbolt.org/g/7DaPHG 然而,只要打开任何优化(-O...-O3),生成的代码对于两者都是相同的(长度)。

不,适用于“仿佛”规则。如果优化器足够聪明,它可以生成相同的代码。在上面的例子中,大多数编译器都是这样的,因为a[i]只是被解析为与*(a+i)相同的预优化数据。 - Goswin von Brederlow
1
如果优化器足够智能,它可以生成相同的代码。在上面的示例中,大多数编译器都可以做到这一点。这基本上就是我想说的 :) - JimmyB

6

标准规定当arr是一个数组对象时,arr[i]的行为相当于将arr分解为指针、加上i并对结果进行解引用。虽然在所有标准定义的情况下这些行为都是等效的,但有些情况下编译器会有用的处理操作,即使标准并不要求,因此arrayLvalue[i]*(arrayLvalue+i)的处理可能会因此而不同。

例如,给定以下内容:

char arr[5][5];
union { unsigned short h[4]; unsigned int w[2]; } u;

int atest1(int i, int j)
{
if (arr[1][i])
    arr[0][j]++;
return arr[1][i];
}
int atest2(int i, int j)
{
if (*(arr[1]+i))
    *((arr[0])+j)+=1;
return *(arr[1]+i);
}
int utest1(int i, int j)
{
    if (u.h[i])
        u.w[j]=1;
    return u.h[i];
}
int utest2(int i, int j)
{
    if (*(u.h+i))
        *(u.w+j)=1;
    return *(u.h+i);
}

GCC为test1生成的代码将假定arr[1][i]和arr[0][j]不会别名,但为test2生成的代码将允许使用指针算术运算来访问整个数组。另一方面,gcc将认识到在utest1中,左值表达式u.h[i]和u.w[j]都访问同一个联合体,但它并不足够复杂以注意到在utest2中*(u.h+i)和*(u.w+j)也是如此。


3
让我试着“在狭义范围内”回答这个问题(其他人已经解释了为什么“原样”描述有点不完整/误导):

在什么情况下,任何编译器会为这两者生成不同的代码?

“不太优化”的编译器可能会在几乎任何情况下生成不同的代码,因为在解析时存在差异:x[y]是一个表达式(索引到数组),而*(x+y)两个 表达式(将整数加到指针上,然后进行间接引用)。当然,即使在解析时,很容易识别并将它们视为相同的表达式,但如果你正在编写一个简单/快速的编译器,那就要避免将“太多智能”放入其中。这里举个例子:
char vector[] = ...;
char f(int i) {
    return vector[i];
}
char g(int i) {
    return *(vector + i);
}

编译器在解析f()时,看到“索引”,可能会生成类似以下的代码(针对某种类似于68000的CPU):
MOVE D0, [A0 + D1] ; A0/vector, D1/i, D0/result of function

然而,对于g(),编译器看到两件事情:首先是解引用("something yet to come"),然后是将整数添加到指针/数组中,因此它不是非常优化,最终可能会得到以下结果:

MOVE A1, A0   ; A1/t = A0/vector
ADD A1, D1    ; t += i/D1
MOVE D0, [A1] ; D0/result = *t

显然,这是非常依赖于具体实现的,某些编译器也可能不喜欢使用类似于f()中使用的复杂指令(使用复杂指令使得调试编译器更加困难),CPU也可能没有这样的复杂指令等等。

“从基础位置移动”和“添加到基础位置”有区别吗?

书中描述可能并不准确。但我认为作者想要描述以上所示的区别- 索引(“从基础位置移动”)是一个表达式,而“添加并解引用”是两个表达式。

这涉及到编译器实现,而不是语言定义,这一点在书中也应明确说明。


2
我测试了一些编译器的变化,大多数都给出了相同的汇编代码(在未优化的x86上测试)。有趣的是,gcc 4.4.7 正好做到了你提到的事情: 例如:

C-Code

Assembly code

其他语言如ARM或MIPS有时也会做同样的事情,但我没有测试过所有情况。因此似乎存在差异,但后来的gcc版本“修复”了这个错误。

你尝试过优化吗? - Antti Haapala -- Слава Україні
不好意思,这些示例太简单了,所有内容都会被优化掉。如果您愿意,可以在Compiler Explorer上尝试一下。 - RoQuOTriX
将数组声明为“volatile”,代码就不会被优化掉。 - JimmyB
使用 -O>1 汇编输出对于以下两种情形都有效:mov eax, DWORD PTR vector[rip+4] - RoQuOTriX

-2

这是C语言中使用的示例数组语法。

int a[10] = {1,2,3,4,5,6,7,8,9,10};

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接