3

C/C++ 中的 strict aliasing

 2 years ago
source link: http://www.kongjunblog.xyz/2021/04/cc-strict-aliasing.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

C/C++ 中的 strict aliasing 跳至主要内容

C/C++ 中的 strict aliasing

C/C++ 中的变量占有一块内存,这时这个变量就是这块内存的别名,指针也可以指向内存,因此同一块内存可能会有多个别名。

int main()
{
    int i = 0;
    int *ip = &i;
}

其中iip是同一块内存,都是它的别名。

内存别名的存在会影响编译器生成的代码的行为。

考虑以下代码块(来自 CSAPP 5.1 节):

void twiddle1(long *xp, long *yp)
{
    *xp += *yp;
    *xp += yp;
}
void tiwddle2(long *xp, long *yp)
{
    *xp *= 2 * *yp;
}

这两个函数的功能看起来是相同的,但其实不然。加入,xpyp指向同一块内存,twiddle1()*xp写为原来的四倍,而twiddle2()xp写为原来的两倍。

编译器在进行优化时,要确保优化是安全的,即优化的程序和未优化的程序行为是一致的。在上面的例子中,编译器无法判断xpyp是同一块内存的别名(指向同一块内存),只能保守地认为两个指针指向同一块内存,因此twiddle()要老老实实的进行两次+=

而当指针指向的对象类型不同时,编译器可以放心地认为指针指向不同的内存,互相不为别名,这就是所谓的 strict aliasing不同类型的指针指向不同的内存块。在这种情况下,编译器可以使用激进的优化策略。

但是 C/C++ 经常使用类型转换和指针直接操作内存,有时就会破坏 strict aliasing 规则,导致未定义行为

xxxxxxxxxx
#include <stdio.h>
int global = 2;
int test_strict_aliasing(int *arg)
{
    global = 1;
    *reinterpret_cast<float*>(arg) = 0;
    return global;
}
int
main()
{
    printf("%d\n", test_strict_aliasing(&global));
    printf("global: %d\n", global);
    return 0;
}
----------------------------------------------------------------------
g++ -Wall -Wstrict-aliasing=1 -o strict-aliasing strict-aliasing.cpp
----------------------------------------------------------------------
0
global: 0
------------------------------------------------------------------------
g++ -Wall -Wstrict-aliasing=1 -O2 -o strict-aliasing strict-aliasing.cpp
------------------------------------------------------------------------
0
global: 1

在上面的程序中reinterpret_cast<float *>(arg)创建了一个临时的float *指针,并且指向的内存块和int *类型指针arg相同,这是非法的内存别名,这反了 strict aliasing 规则,产生未定义行为。在 GCC O2 以下的优化级别,不假设 strict aliasing, 在 O2 及以上优化级别假设 strict aliasing,因此两编译选项下程序的行为不同。

xxxxxxxxxx
g++ -Wall -Wstrict-aliasing=1 -O2 -o strict-aliasing strict-aliasing.cpp 生成的汇编代码
  401170:   c7 05 aa 2e 00 00 01    movl   $0x1,0x2eaa(%rip)        # 404024 <global>
  401177:   00 00 00
  40117a:   b8 01 00 00 00          mov    $0x1,%eax
  40117f:   c7 07 00 00 00 00       movl   $0x0,(%rdi)
  401185:   c3                      retq
  401186:   66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
  40118d:   00 00 00
g++ -Wall -Wstrict-aliasing=1 -o strict-aliasing strict-aliasing.cpp 生成的汇编代码
  401126:   55                      push   %rbp
  401127:   48 89 e5                mov    %rsp,%rbp
  40112a:   48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40112e:   c7 05 ec 2e 00 00 01    movl   $0x1,0x2eec(%rip)        # 404024 <global>
  401135:   00 00 00
  401138:   48 8b 45 f8             mov    -0x8(%rbp),%rax
  40113c:   66 0f ef c0             pxor   %xmm0,%xmm0
  401140:   f3 0f 11 00             movss  %xmm0,(%rax)
  401144:   8b 05 da 2e 00 00       mov    0x2eda(%rip),%eax        # 404024 <global>
  40114a:   5d                      pop    %rbp
  40114b:   c3                      retq

可以发现 O2 编译选项下生成的代码更少,性能更强。O2 下的代码直接将数字1当成返回值返回,而不是将global当成返回值返回,因此程序出现了错误的行为。

将指针转型为不相容(imcompatible的指针类型,并进行读写违反了 strict aliasing,是严重的未定义行为。如果确实需要进行 type punning,必须将指针转换为相容的指针类型,即通过合法的内存别名访问内存。C/C++ 标准规定了以下类型的指针是合法的别名:

  1. 指针指向的类型相差unsignedsignedvolatile
  2. char *void *是所有指针的合法别名
  3. 指向包含指针指向对象类型的聚合类或 union 的指针是合法别名

使用char *创建合法别名

char在 C/C++ 中实际上是字节类型,使用非常频繁,因此在标准中为它开了“后门”。

以下程序避免了未定义行为:

xxxxxxxxxx
int test_strict_aliasing(int *arg)
{
    global = 1;
    *reinterpret_cast<float*>(arg) = 0;
    *reinterpret_cast<char *>(arg) = 0;
    *(reinterpret_cast<char *>(arg) + 1) = 0;
    *(reinterpret_cast<char *>(arg) + 2) = 0;
    *(reinterpret_cast<char *>(arg) + 3) = 0;
    return global;
}

使用union * 创建合法别名

xxxxxxxxxx
union int2float
{
    int i;
    float f;
};
int test_strict_aliasing(int *arg)
{
    global = 1;
    *reinterpret_cast<float*>(arg) = 0;
    return global;
}

上面的程序定义了一个包含我们要修改的指针指向的对象类型(int)的联合体,然后将arg转型为union int2float *再通过 union 修改*arg。这种方法是 GCC 推荐的方法。

禁止编译器假设 strict aliasing

上面提到,在 O2 及以上优化等级才会 假设 strict aliasing ,有大量的 C/C++ 程序必须违反 strict aliasing,因此 GCC 提供了-fstrict-aliasing-fno-strict-aliasing选项开启和关闭 strict aliasing。

GCC 还提供了-Wstrict-aliasing来警告违反 strict-aliaisng 的行为,这个选项被-Wall默认开启。GCC 虽然提供了警告选项,但该功能工作的并不好。

GCC 对 strict aliasing 的处理让很多 C/C++ 程序不加上-no-strict-aliasing选项就无法正确运行,这引起了很多人的愤怒,Linus 还专门喷过 GCC,但是经过我通过实验发现,Clang 也和 GCC 一样烂。

总之,要避免直接对指针进行转型并读写,这是未定义的!!!

C++

此博客中的热门博文

使用 Vim 搭建 C/C++ 开发环境

刚接触 Vim 的同学往往因为无法搭建开发环境而“从入门到放弃”,本文旨在帮助这些同学搭建开发环境,聚焦于最核心的开发需求,忽略换配色调字体之类的细枝末节。如果需要开箱即用的 vim 配置(发行版),可以使用 Spacevim 。 本文使用 neovim-nightly,但也适用于 Vim 8.2+,不需要读者有任何 VimL 基础,以 C/C++ 为例,但应该适用于任何语言。   插件管理 在 Vim 中,插件只是一些脚本,存放在特定的目录中,运行时将它们所在的目录加入到 runtimepath 中。Vim 8 内置了插件管理功能,但不支持高级的插件管理功能。Vimmers 实现了多个插件管理器,可以自动下载、更新、安装插件,还可以延迟加载、按需加载,提高启动速度。 上古时期流行手动或使用 Vundle 管理插件,以上两种方式已经落伍了,这里介绍目前比较流行的三个插件管理器: vim-plug :简单易用高效,是目前最流行的插件管理器 dein.vim :功能强大,但使用复杂 vim-pathogen :另一款流行的插件管理器,没有用过不做评价 以上三款插件管理器风格各不相同,都有大量用户,功能相当完善,根据自己的喜好选取即可。推荐新手选择 vim-plug,对启动时间特别敏感的同学可以考虑 dein.vim,我的配置在安装 70 余个插件的情况下,启动仅需 60 余秒。 使用 vim-plug 安装插件只需要在 .vimrc 中写入以下代码: call plug#begin('~/.vim/plugged') " 括号里面是插件目录                                 " 只能在 plug#begin() 和 plug#end() 之间写安装插件的命令 Plug 'junegunn/vim-easy-align'   " 用户名/插件名,默认从 github 下载安装 Plug 'https://github.com/junegunn/vim-github-dashboard.git' " 从特定 URL 下载安装 call plug#end() 使用 dein.vim 安装插件: ​ x set runtimepath+=

Ibex 架构介绍

  Ibex 是什么? Ibex was initially developed as part of the PULP platform under the name "Zero-riscy" , and has been contributed to lowRISC who maintains it and develops it further. It is under active development. Ibex 是一个产品级的 32 位开源 RISC-V 处理器,使用 SystemVerilog 编写,麻雀虽小(11000 行左右),五章俱全。支持 RV32I、RV32C、RV32M、RV32B 等拓展,支持了 M-Mode 和 U-Mode,完整实现了 RISC-V 指令集规定的控制状态寄存器、中断异常、调试支持等,适用于嵌入式系统。 总体架构如下: 流水线 Ibex 默认使用两级流水线,但也支持三级流水线(实验性特性)。两级流水分别为: 取值(IF):通过预取缓冲区(prefetch buffer)从内存中取值,可以一个周期取一条指令,只要指令侧内存支持。 译码/执行(ID/EX):译码并立即执行,所有的操作,包括寄存器读写、内存访问都在该阶段进行。 Ibex 支持多周期指令,每条指令都至少需要两个周期才能通过流水线,周期数更大的指令将导致流水线停顿多个周期。指令类型及其停顿周期如下: Instruction Type Stall Cycles Description Integer Computational 0 Integer Computational Instructions are defined in the RISCV-V RV32I Base Integer Instruction Set. CSR Access 0 CSR Access Instruction are defined in ‘Zicsr’ of the RISC-V specification. Load/Store 1 - N Both loads and stores stall for at least one cycle to await a response. For loads this response is t

UNIX 进程关系

进程的起源 BSD 终端登录 网络登录 进程与进程组 会话与控制终端 作业控制 孤儿进程和孤儿进程组 总结 UNIX是分时系统,同时运行着多个进程,进程之间相互联系,形成了进程组、会话等进程关系,这些进程关系会影响某些函数/系统调用和信号的行为。 进程的起源 所有的进程都有一共同的起源,加电开机启动操作系统并登录(获取 login shell )就是用户进程的起始 1 。这里介绍传统的UNIX登录机制。 UNIX登录的过程一般分为两种: 终端登录( terminal login ) 网络登录( network login ) 终端登录就是在本地计算机中启动操作系统并取得 login shell,比如没有安装桌面环境(KDE、GNOME等)的 GNU/Linux 系统,启动后就是命令行,要求输入用户名和密码,验证通过后就会取得 login shell。 网络登录就是通过网络登录远程计算机取得 login shell,比如腾讯云、阿里云的 Linux 服务器,登录后就是命令行黑框框,就好像是在本地登录的一样。 BSD 终端登录 启动 init 进程,init 进程可能会读取终端相关的配置文件,如 /etc/ttys 等。 为每个终端 fork 出一个子进程,并使用 exec() 函数执行 getty() 例程。 getty() 例程打开终端设备(标准输出、标准输入、标准错误)、读取用户名、初始化环境变量,并使用 exec() 系列函数执行login 例程。 login 例程接收密码,如果验证成功就修改工作目录到家目录、修改终端所有权、设置用户信息(UID、GID等)、初始化环境变量(SHELL、PATH等)后执行 login shell。 如果验证失败,终止进程,init 进程再次执行步骤2。 通过以上步骤,用户就取得了 login shell,并且 login shell 是 init 进程的子进程(exec 系列函数只执行程序不改变进程ID)。login shell 也会读取它的配置文件来初始化自身。 BSD 的终端登录方式在 UNIX 世界有巨大的影响,被许多 UNIX 实现采用,比如 Linux 早期版本(Linux 0.12)就是用类似 BSD 的登录方式。现在终端登录复杂了很多,许多系统装有 X window,登录后是图形界面而不是字符界面。G

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK