【问题标题】:Array-syntax vs pointer-syntax and code generation?数组语法与指针语法和代码生成?
【发布时间】:2018-12-24 16:35:39
【问题描述】:

在书中,"Understanding and Using C Pointers" by Richard Reese 在第 85 页上写着,

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

vector[i] 生成的代码与*(vector+i) 生成的代码不同。符号vector[i] 生成从位置向量开始的机器代码,移动 i 从该位置定位,并使用其内容。符号 *(vector+i) 生成从位置 vector 开始的机器代码,添加 i 到地址,然后使用该地址的内容。虽然结果相同,但生成的机器代码不同。这种差异对大多数程序员来说很少有重要意义。

您可以看到excerpt here。这段话是什么意思?在什么情况下,任何编译器都会为这两者生成不同的代码?从基地“移动”和“添加”到基地有区别吗?我无法让它在 GCC 上工作——生成不同的机器代码。

【问题讨论】:

  • 我能想到的情况是使用内存检查工具和代码检查工具,因为它们确实试图考虑代码的预期含义。
  • "生成的机器码不同。"对于大多数当代编译器来说可能是错误的。
  • 对我来说,“将 i 添加到地址”意味着移动 i 个字节。但实际发生的是它增加了 i * sizeof(&vector[0]) 字节。正如其他人指出的那样,C 标准将 a[i] 声明为 *(a + i),所以这段话简直令人困惑。
  • @KonradRudolph 我有很多关于书籍和已发表博客的指针的问题,如果你想毁掉你的一周,我认为这些都是勘误表。 stackoverflow.com/q/51227140/124486 更多内容也在筹备中。
  • 如果下标是常量表达式(如a[5]),编译器会生成不同的代码,因为它可以在编译时计算偏移量,但我看不出a[i]和@如何在翻译时不知道i 的值的情况下,987654334@ 的处理方式将有所不同。

标签: c arrays pointers pointer-arithmetic errata


【解决方案1】:

引用是错误的。相当悲惨的是,这种垃圾在这十年里仍然出版。事实上,C 标准将x[y] 定义为*(x+y)

页面后面关于左值的部分也是完全错误的。

恕我直言,使用这本书的最佳方法是将其放入回收箱或烧掉。

【讨论】:

  • 我不会说这是,但不完整。问题是,some 编译器可能会为x[y] 生成与*(x+y) 不同的机器代码(实际上,*(y+x)y[x] 也是如此)。 IOW,如果我们在整个引用前加上“在某些编译器上...”,那实际上是对的。
  • 另外,日常的方式是用这本书作为一杯热咖啡的支架。
  • @srdjan.veljkovic 如果您使用“在某些编译器上”对其进行限定,几乎一切皆有可能。可以想象,它可以根据月相生成不同的代码。如果这本书说“机器代码可能不同”,这不是什么大问题。
  • 如果联合包含一个数组,gcc 将为theUnion.anArray[i]*(theUnion.anArray+i) 生成不同的机器码。只有在前一种情况下,gcc 才会足够聪明地认识到对anArray[i] 的访问可能会影响联合及其其他成员。
  • @NicHartley:GCC 使用代码生成/执行模型,将 aggregate.memberaggregate.member[index]aggregate.member[index1][index2] 等形式的左值表达式视为对聚合的操作,但无法将指向聚合成员的指针识别为与聚合有任何关系,即使在立即使用该指针的情况下也是如此。该标准将对此类构造的支持视为实施质量问题,而 gcc 的设计是围绕该标准允许低质量实施这一事实而设计的。
【解决方案2】:

我有 2 个 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] 指定E2-E1 的第一个元素(从零开始计数)。

此外,as-if 规则适用于此 - 如果程序的行为相同,编译器可以生成相同的代码,即使语义不是 一样。

【讨论】:

  • 这是对特定编译器和优化的假设,但通常与我所做的相同。我不满意,因为这样的测试基于架构的字节码假设语言。
  • 有趣的注释:根据引用和实践,i[vector] 也有效,尽管在大多数情况下这样做会很糟糕。您不能从位置 i 开始并从该位置移动 vector 位置。
  • “我不满意,因为这样的测试对基于架构的字节码的语言做出假设”关键是我们正在讨论生成的程序集,它与实现特定你可以得到。对于这种说法,除了注意标准规定的可观察行为的等效性之外,您唯一能做的就是查看各种编译器的输出。
  • @DanielH - 它确实有效,并且 IIRC 在 国际混淆 C 代码竞赛 中多次使用该语法。 (有时以'c'[someptr] 或更糟的形式。)我有这种模糊的记忆,曾经检查过n["0123456789ABCDEF"] 的代码,主要是为了搞乱正在做代码审查的朋友......我想回头看我不应该为此感到自豪......我只希望我能记住他的反应......
  • @davidbak 是的,确实如此,但 OP 的书的描述暗示它没有。而且,你知道,无论如何你都不应该这样做。
【解决方案3】:

引用的段落是完全错误的。表达式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 是一个指针。常见问题列表不是在讨论 a[3]*(a+3),而是讨论 a[3](或 *(a+3)),其中 a 是一个数组,而 p[3](或 *(p+3))是 p一个指针。 (当然这两种情况会产生不同的代码,因为数组和指针是不同的。正如FAQ列表所解释的,从指针变量中获取地址与使用数组的地址根本不同。)

【讨论】:

  • 你提到了旧的 C 常见问题解答,这是一个真的很好的发现。但是即使在这种情况下,您也不会说出为什么它们不同:您只是说 (当然,这两种情况会生成不同的代码,因为数组和指针是不同的。) 也许一些解释是很好。
  • 标准可能将表达式视为等价,但许多编译器仅将其解释为暗示在标准定义一个行为的情况下,它定义了两者的行为。该标准没有对访问联合内的非字符数组元素的行为(或者,就此而言,任何非字符联合成员)的行为施加任何要求,但如果这些数组没有表现出类型,那么它们将毫无用处 -像其他成员一样的双关语行为。 GCC 会以这种方式处理 someUnion.array[i],但不会对 *(someUnion.arr+i) 这样做。
  • “完全相同,并且可以预期在所有情况下生成相同的代码” - 这不是标准所说的。定义是关于语义的,所以在这两种情况下,你肯定会得到相同的数组内容。 如何实现这取决于编译器,并且可能因编译器、平台和优化级别而异。
【解决方案4】:

认为原文可能指的是某些编译器可能会或可能不会执行的一些优化。

例子:

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) 相同的预优化数据。
  • @GoswinvonBrederlow "如果优化器足够聪明,它可以生成相同的代码。在上面的例子中,大多数编译器是"-基本上就是这样我想说:)
【解决方案5】:

标准指定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 中,左值表达式 uh[i] 和 uw[j] 都访问同一个联合,但它还不够复杂,无法注意到 *(u.h+i) 和 *(u.w+ j) 在 utest2 中。

【讨论】:

    【解决方案6】:

    让我试着“狭隘地”回答这个问题(其他人已经描述了为什么“按现状”的描述有些缺乏/不完整/误导):

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

    “不是非常优化”的编译器可能会在几乎任何上下文中生成不同的代码,因为在解析时存在差异: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
    

    OTOH,对于g(),编译器会看到两件事:首先是取消引用(“即将发生的事情”),然后将整数添加到指针/数组,因此不是非常优化,它可能会结束与:

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

    显然,这非常依赖于实现,一些编译器可能也不喜欢使用 f() 所使用的复杂指令(使用复杂指令会使编译器更难调试),CPU 可能没有如此复杂的指令,等等。

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

    书中的描述可以说是措辞不当。但是,我认为作者想描述上面显示的区别 - 索引(从基础“移动”)是一种表达方式,而“添加然后取消引用”是两种表达方式。

    这是关于编译器实现不是语言定义,区别应该在书中也明确指出。

    【讨论】:

      【解决方案7】:

      我测试了一些编译器变体的代码,它们中的大多数都为两条指令提供了相同的汇编代码(针对 x86 进行了测试,没有进行优化)。 有趣的是,gcc 4.4.7 完全符合您所提到的: 示例:

      ARM 或 MIPS 等其他语言有时也会这样做,但我没有全部测试。所以看起来他们是有区别的,但后来的 gcc 版本“修复”了这个错误。

      【讨论】:

      • 你尝试过优化吗?
      • 不,抱歉这些例子太琐碎了,一切都会被优化出来。喜欢的话可以Compiler Explorer试试
      • 声明数组为volatile,代码不会被优化掉。
      • with -O>1 汇编器输出同时用于:`mov eax, DWORD PTR vector[rip+4]`
      【解决方案8】:

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

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

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2015-07-05
        • 1970-01-01
        • 1970-01-01
        • 2016-12-27
        • 2023-03-23
        相关资源
        最近更新 更多