【问题标题】:Is it possible to write to an array second element by overflowing the first element in C?是否可以通过溢出 C 中的第一个元素来写入数组的第二个元素?
【发布时间】:2018-10-27 15:56:30
【问题描述】:

在低级语言中,可以将mov 一个双字(32 位)写入第一个数组元素,这将溢出以写入第二个、第三个和第四个元素,或者mov 一个字(16 位)写入第一个,它将溢出到第二个元素。

如何在c中达到同样的效果?例如尝试时:

char txt[] = {0, 0};
txt[0] = 0x4142;

它会发出警告[-Woverflow]

txt[1] 的值不变txt[0] 设置为0x42

如何获得与汇编中相同的行为:

mov word [txt], 0x4142

之前的汇编指令将设置第一个元素[txt+0]0x42,第二个元素[txt+1]0x41

编辑

这个建议怎么样?

将数组定义为单个变量。

uint16_t txt;
txt = 0x4142;

并使用((uint8_t*) &txt)[0] 访问第一个元素和((uint8_t*) &txt)[1] 访问第二个元素。

【问题讨论】:

  • *(uint16_t*)txt = 0x4142;
  • 在 C 语言中,您可以使用 union 进行类似的类型双关语。在 C 和 C++ 中,您都可以使用 memcpy。我不确定@Jester 评论中显示的转换是否不会破坏严格的别名,并且这种转换的规则在 C 和 C++ 中可能不同(所以请选择 one 编程语言) .
  • @Someprogrammerdude (uint16_t*)txt,至少,由于对齐问题,是一个问题。
  • @Someprogrammerdude 我相信这将是一个严格的别名违规。地址的开头不是uint16_t
  • -Woverflow 表示 char 不能表示值 0x4142 的事实(至少在您的目标系统上)。值 0x4142 将在执行分配之前转换为 char。如果charunsigned,则转换将使用模运算来生成char 可以表示的范围内的值。因此分配txt[0] = 0x4142; 不会影响txt[1]。如果charsigned,则转换的结果是未定义的行为。简而言之,分配txt[0] = some_integral_value 更改txt[1] 没有明确的方式。

标签: c++ c assembly nasm


【解决方案1】:

如果您完全确定这不会导致分段错误,您一定会这样做,您可以使用memcpy()

uint16_t n = 0x4142;
memcpy((void *)txt, (void *)&n, sizeof(uint16_t));

通过使用void pointers,这是最通用的解决方案,可推广到本示例之外的所有情况。

【讨论】:

  • 不需要强制转换为 void
  • 它说明了一点。 OP 可能需要知识,而不是特定案例的解决方案。
  • @MartinChekurov 不需要强制转换为 void 鉴于问题也(当前)标记为 C++,它可能是。
  • @MartinChekurov 确实。
  • 在具体示例中是的,因为已知n 的大小不会溢出。通常,最好将参数强制为安全的已知大小。
【解决方案2】:

您正在寻找一个deep copy,您需要使用循环来完成(或在内部为您执行循环的函数:memcpy)。

简单地将0x4142 分配给char 将不得不被截断以适应char。这应该会引发警告,因为结果将是特定于实现的,但通常会保留最低有效位。


无论如何,如果您知道要分配的数字,您可以使用它们来构造:const char txt[] = { '\x41', '\x42' };


我建议使用初始化列表来执行此操作,显然您需要确保初始化列表至少与size(txt) 一样长。例如:

copy_n(begin({ '\x41', '\x42' }), size(txt), begin(txt));

Live Example

【讨论】:

  • char txt[] = { '\x41', '\x42' }; 还需要有关字节序的知识。鉴于 x86(由 NASM 标记暗示)是小端,OP 可能需要相反的顺序。
  • @chux 没有?字符没有字节序。如果您是说 OP 可能有意 const char txt[] = { '\x412, '\x41' },那么是的,我认为他能够写出来?
  • 我的评论是基于“第一个元素 [txt+0] 到 0x42,第二个元素 [txt+1] 到 0x41”,而char txt[] = { '\x41', '\x42' }; 不必要地显示了相反的结果。
  • @chux 好的,我明白你在说什么。我认为这个问题在战略上有点问题。从较大的整数类型转换为char[] 似乎很难做到这一点。我已经更新了我的答案,以包括使用initializer_list 的建议,它不会受到字节序歧义的影响,并且将独立于平台。
【解决方案3】:

txt[0] = 0x4142; 是对 char 对象的赋值,因此右侧在被评估后被隐式转换为 (char)

等效的 NASM 是 mov byte [rsp-4], 'BA'。用 NASM 组装它会给你和你的 C 编译器一样的警告:

foo.asm:1: warning: byte data exceeds bounds [-w+number-overflow]

此外,现代 C 不是高级汇编程序。 C 有类型,NASM 没有(操作数大小仅基于每条指令)。不要期望 C 像 NASM 一样工作。

C 被定义为“抽象机器”,编译器的工作是为目标 CPU 生成 asm,它产生相同的可观察结果 就好像 C 直接在 C 抽象机器上运行。除非您使用volatile,否则实际存储到内存中不算作可观察到的副作用。这就是 C 编译器可以将变量保存在寄存器中的原因。

更重要的是,在为 x86 编译时,根据 ISO C 标准未定义的行为可能仍然是未定义的。例如,x86 asm 对有符号溢出有明确定义的行为:它环绕。但在 C 中,这是未定义的行为,因此编译器可以利用这一点为for (int i=0 ; i<=len ;i++) arr[i] *= 2; 生成更高效的代码,而不必担心i<=len 可能始终为真,从而产生无限循环。请参阅 What Every C Programmer Should Know About Undefined Behavior

char*unsigned char*(或__m128i* 和其他英特尔SSE/AVX 内在类型,因为它们也被定义为may_alias 类型)之外的指针强制类型转换违反了严格-别名规则。 txt 是一个字符数组,但我认为通过uint16_t* 写入它然后通过txt[0]txt[1] 读取它仍然是一个严格的别名违规。

某些编译器可能会定义 *(uint16_t*)txt = 0x4142 的行为,或者在某些情况下发生以生成您期望的代码,但您不应该指望它始终有效且安全,其他代码也会读取并写txt[]

允许编译器(即 C 实现,使用 ISO 标准的术语)定义 C 标准未定义的行为。但是为了追求更高的性能,他们选择留下很多未定义的东西。 这就是为什么为 x86 编译 C类似于直接用 asm 编写

许多人认为现代 C 编译器对程序员怀有敌意,寻找“错误编译”代码的借口。请参阅gcc, strict-aliasing, and horror stories 上此答案的第二半部分以及 cmets。 (该答案中的示例使用正确的memcpy 是安全的;问题是a custom implementation of memcpy that copied using long*。)


Here's a real-life example of a misaligned pointer leading to a fault on x86(因为 gcc 的自动矢量化策略假设某些整数会达到 16 字节对齐边界。即它取决于 uint16_t* 是否对齐。) p>


显然,如果您希望您的 C 具有可移植性(包括到非 x86),则必须使用明确定义的方式来进行类型双关。在 ISO C99 及更高版本中,写入一个联合成员并读取另一个是明确定义的。 (在 GNU C++ 和 GNU C89 中)。

在 ISO C++ 中,唯一明确定义的类型双关方法是使用 memcpy 或其他 char* 访问来复制对象表示。

现代编译器知道如何针对较小的编译时常量大小优化 memcpy

#include <string.h>
#include <stdint.h>
void set2bytes_safe(char *p) {
    uint16_t src = 0x4142;
    memcpy(p, &src, sizeof(src));
}

void set2bytes_alias(char *p) {
    *(uint16_t*)p = 0x4142;
}

两个函数 compile to the same code 与 gcc、clang 和 ICC 用于 x86-64 System V ABI:

# clang++6.0 -O3 -march=sandybridge
set2bytes_safe(char*):
    mov     word ptr [rdi], 16706
    ret

Sandybridge 系列没有针对 16 位 mov 立即数的 LCP 解码停顿,仅针对带有 ALU 指令的 16 位立即数。这是对 Nehalem 的改进(参见 Agner Fog's microarch guide),但显然 gcc8.1 -march=sandybridge 并不知道,因为它仍然喜欢:

    # gcc and ICC
    mov     eax, 16706
    mov     WORD PTR [rdi], ax
    ret

将数组定义为单个变量。

...并使用((uint8_t*) &amp;txt)[0]访问元素

是的,这很好,假设uint8_tunsigned char,因为char* 可以给任何东西起别名。

几乎所有支持 uint8_t 的实现都是这种情况,但理论上可以在不支持的地方构建一个,char 是 16 位或 32 位类型,uint8_t 是用包含单词的更昂贵的读取/修改/写入。

【讨论】:

    【解决方案4】:

    一种选择是信任您的编译器(tm)并编写正确的代码。

    使用此测试代码:

    #include <iostream>
    
    int main() {
        char txt[] = {0, 0};
        txt[0] = 0x41;
        txt[1] = 0x42;
    
        std::cout << txt;
    }
    

    Clang 6.0 产生:

    int main() {
    00E91020  push        ebp  
    00E91021  mov         ebp,esp  
    00E91023  push        eax  
    00E91024  lea         eax,[ebp-2]  
    char txt[] = {0, 0};
    00E91027  mov         word ptr [ebp-2],4241h    <-- Combined write, without any tricks!
    txt[0] = 0x41;
    txt[1] = 0x42;
    
    std::cout << txt;
    00E9102D  push        eax  
    00E9102E  push        offset cout (0E99540h)  
    00E91033  call        std::operator<<<std::char_traits<char> > (0E91050h)  
    00E91038  add         esp,8  
    }
    00E9103B  xor         eax,eax  
    00E9103D  add         esp,4  
    00E91040  pop         ebp  
    00E91041  ret  
    

    【讨论】:

    • +1,但请注意 gcc6 及更早版本不进行存储合并 (godbolt.org/g/HZqFLK)。 ICC18 还将其编译为两个单独的字节存储。不过,gcc7/8、clang 和 MSVC 都做对了。但请注意,在 x86 这样的小端目标上,这仅与来自 0x4142memcpy 相同。但是您更有可能知道内存中字节的顺序,所以这可能是这段代码的优势,而memcpy 的劣势!
    猜你喜欢
    • 2012-06-12
    • 2022-11-16
    • 2022-11-12
    • 2018-06-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多