为什么 gcc 会重新排序函数中的局部变量?
Why does gcc reorder the local variable in function?
我写了一个 C 程序,只是 read/write 一个大数组。我用命令 gcc -O0 program.c -o program
编译了程序 出于好奇,我用 objdump -S
命令反汇编了 C 程序。
read_array
和write_array
函数的代码和汇编附在本题末尾
我正在尝试解释 gcc 是如何编译该函数的。我使用 //
添加我的评论和问题
取write_array()
函数的汇编代码开头一段
4008c1: 48 89 7d e8 mov %rdi,-0x18(%rbp) // this is the first parameter of the fuction
4008c5: 48 89 75 e0 mov %rsi,-0x20(%rbp) // this is the second parameter of the fuction
4008c9: c6 45 ff 01 movb [=10=]x1,-0x1(%rbp) // comparing with the source code, I think this is the `char tmp` variable
4008cd: c7 45 f8 00 00 00 00 movl [=10=]x0,-0x8(%rbp) // this should be the `int i` variable.
我不明白的是:
1) char tmp
显然是在write_array
函数中afterint i
定义的。为什么 gcc 对这两个局部变量的内存位置重新排序?
2) 从偏移量来看,int i
在-0x8(%rbp)
,char tmp
在-0x1(%rbp)
,这表明变量int i
需要7 字节?这很奇怪,因为 int i
在 x86-64 机器上应该是 4 个字节。不是吗?我的猜测是 gcc 试图做一些调整?
3) 我发现 gcc 优化选项非常 有趣 。有什么好的 documents/book 可以解释 gcc 是如何工作的吗? (第三个问题可能跑题了,如果你这么认为,请忽略。我只是想看看有没有捷径可以学习gcc用于编译的底层机制。:-))
下面是一段函数代码:
#define CACHE_LINE_SIZE 64
static inline void
read_array(char* array, long size)
{
int i;
char tmp;
for ( i = 0; i < size; i+= CACHE_LINE_SIZE )
{
tmp = array[i];
}
return;
}
static inline void
write_array(char* array, long size)
{
int i;
char tmp = 1;
for ( i = 0; i < size; i+= CACHE_LINE_SIZE )
{
array[i] = tmp;
}
return;
}
下面是 write_array
的一段反汇编代码,来自 gcc -O0:
00000000004008bd <write_array>:
4008bd: 55 push %rbp
4008be: 48 89 e5 mov %rsp,%rbp
4008c1: 48 89 7d e8 mov %rdi,-0x18(%rbp)
4008c5: 48 89 75 e0 mov %rsi,-0x20(%rbp)
4008c9: c6 45 ff 01 movb [=12=]x1,-0x1(%rbp)
4008cd: c7 45 f8 00 00 00 00 movl [=12=]x0,-0x8(%rbp)
4008d4: eb 13 jmp 4008e9 <write_array+0x2c>
4008d6: 8b 45 f8 mov -0x8(%rbp),%eax
4008d9: 48 98 cltq
4008db: 48 03 45 e8 add -0x18(%rbp),%rax
4008df: 0f b6 55 ff movzbl -0x1(%rbp),%edx
4008e3: 88 10 mov %dl,(%rax)
4008e5: 83 45 f8 40 addl [=12=]x40,-0x8(%rbp)
4008e9: 8b 45 f8 mov -0x8(%rbp),%eax
4008ec: 48 98 cltq
4008ee: 48 3b 45 e0 cmp -0x20(%rbp),%rax
4008f2: 7c e2 jl 4008d6 <write_array+0x19>
4008f4: 5d pop %rbp
4008f5: c3 retq
对于保存在栈中的局部变量,地址顺序取决于栈的增长方向。您可以参考Does stack grow upward or downward?了解更多信息。
This is quite weird because int i should be 4 bytes on x86-64 machine. Isn't it?
如果我没记错的话,x86-64 机器上 int 的大小是 8。你可以通过编写一个测试应用程序来打印来确认它 sizeof(int)
。
即使在 -O0
,gcc 也不会发出 static inline
函数的定义,除非有调用者。在那种情况下,它实际上并没有内联:而是发出一个独立的定义。所以我猜你的反汇编是从那个开始的。
您使用的是非常旧的 gcc 版本吗? gcc 4.6.4 将变量按此顺序放入堆栈,但 4.7.3 及更高版本使用其他顺序:
movb , -5(%rbp) #, tmp
movl [=10=], -4(%rbp) #, i
在您的 asm 中,它们按初始化顺序而不是声明顺序存储,但我认为这只是偶然,因为 gcc 4.7 改变了顺序。此外,添加像 int i=1;
这样的初始化程序不会改变分配顺序,因此完全破坏了该理论。
记住 gcc is designed around a series of transformations from source to asm, so -O0
doesn't mean "no optimization"。您应该将 -O0
视为遗漏了 -O3
通常会做的一些事情。没有选项试图从源代码到 asm 进行尽可能逐字的翻译。
一旦 gcc 决定为它们分配 space 的顺序:
位于 rbp-1
的 char
:这是第一个可以容纳 char
的位置。如果还有另一个 char
需要存储,它可以放在 rbp-2
.
int
at rbp-8
:由于从rbp-1
到rbp-4
的4个字节不是空闲的,下一个可用的自然对齐位置是 rbp-8
.
或者对于 gcc 4.7 和更新版本,-4 是 int 的第一个可用位置,-5 是它下面的下一个字节。
回复:space 储蓄:
的确,将字符置于 -5 会产生最低接触地址 %rsp-5
,而不是 %rsp-8
,但这不会保存任何内容。
堆栈指针在 AMD64 SysV ABI 中是 16B 对齐的。 (从技术上讲,%rsp+8
(堆栈参数的开始)在函数入口处对齐,在你推送任何东西之前。)%rbp-8
触摸新页面或缓存行的唯一方法 %rbp-5
不会是堆栈小于 4B 对齐。这是极不可能的,即使在 32 位代码中也是如此。
就函数“分配”或“拥有”多少堆栈而言:在 AMD64 SysV ABI 中,函数“拥有”下方 128B 的红色区域 %rsp
(That size was chosen because a one-byte displacement can go up to -128
) .信号处理程序和 user-space 堆栈的任何其他异步用户将避免破坏红色区域,这就是为什么函数可以写入 %rsp
以下的内存而不递减 %rsp
的原因。所以从这个角度来看,我们使用多少红色区域并不重要;信号处理程序 运行 出栈的几率不受影响。
在没有 redzone 的 32 位代码中,对于任何一个命令,gcc 都在 sub , %esp
的堆栈上保留 space。 (尝试在 godbolt 上使用 -m32
)。所以还是那句话,不管我们用5字节还是8字节都无所谓,因为我们是以16为单位预留的。
当有许多 char
和 int
变量时,gcc 将 char
打包成 4B 组,而不是将 space 丢失到碎片中,即使声明混合在一起:
void many_vars(void) {
char tmp = 1; int i=1;
char t2 = 2; int i2 = 2;
char t3 = 3; int i3 = 3;
char t4 = 4;
}
with gcc 4.6.4 -O0 -fverbose-asm
,这有助于标记哪个存储是哪个变量,这就是编译器 asm 输出优于反汇编的原因:
pushq %rbp #
movq %rsp, %rbp #,
movb , -4(%rbp) #, tmp
movl , -16(%rbp) #, i
movb , -3(%rbp) #, t2
movl , -12(%rbp) #, i2
movb , -2(%rbp) #, t3
movl , -8(%rbp) #, i3
movb , -1(%rbp) #, t4
popq %rbp #
ret
我认为变量在 -O0
.
处根据 gcc 版本以声明的正向或反向顺序排列
我制作了您的 read_array
函数的一个版本,可以优化:
// assumes that size is non-zero. Use a while() instead of do{}while() if you want extra code to check for that case.
void read_array_good(const char* array, size_t size) {
const volatile char *vp = array;
do {
(void) *vp; // this counts as accessing the volatile memory, with gcc/clang at least
vp += CACHE_LINE_SIZE/sizeof(vp[0]);
} while (vp < array+size);
}
Compiles to the following, with gcc 5.3 -O3 -march=haswell:
addq %rdi, %rsi # array, D.2434
.L11:
movzbl (%rdi), %eax # MEM[(const char *)array_1], D.2433
addq , %rdi #, array
cmpq %rsi, %rdi # D.2434, array
jb .L11 #,
ret
将表达式转换为 void 是告诉编译器使用了一个值的规范方法。例如要抑制未使用变量警告,您可以编写 (void)my_unused_var;
.
对于 gcc 和 clang,使用 volatile
指针解除引用确实会生成内存访问,不需要 tmp 变量。 C 标准对于什么构成对 volatile
的访问的内容非常不明确,因此这可能不是完全可移植的。另一种方法是 xor
您读入累加器的值,然后将其存储到全局变量中。只要你不使用全程序优化,编译器就不知道什么都没有读取全局,所以它无法优化计算。
有关第二种技术的示例,请参阅 the vmtouch
source code。 (它实际上为累加器使用了一个全局变量,这使得代码很笨重。当然,这并不重要,因为它涉及页面,而不仅仅是缓存行,所以它很快就会成为 TLB 未命中和页面错误的瓶颈,即使是内存读取 -在循环携带的依赖链中修改写入。)
我尝试编写了一些 gcc 或 clang 将编译为没有序言的函数(假设 size
最初是非零的),但失败了。 GCC 总是希望 add rsi,rdi
用于 cmp/jcc
循环条件,即使 -march=haswell
其中 sub rsi,64
/jae
可以像 cmp/jcc
一样进行宏融合].但总的来说,在 AMD 上,GCC 在循环中的微指令更少。
read_array_handtuned_haswell:
.L0
movzx eax, byte [rdi] ; overwrite the full RAX to avoid any partial-register false deps from writing AL
add rdi, 64
sub rsi, 64
jae .L0 ; or ja, depending on what semantics you want
ret
Godbolt Compiler Explorer link with all my attempts and trial versions
如果循环终止条件是je
,在像do { ... } while( size -= CL_SIZE );
这样的循环中,我可以得到类似的结果,但我似乎无法说服gcc在减法时捕获无符号借用。它想减去然后 cmp -64/jb
来检测下溢。这是 not that hard to get compilers to check the carry flag after an add to detect carry :/
让编译器创建一个 4-insn 循环也很容易,但并非没有序言。例如计算结束指针(数组+大小)并递增指针直到它大于或等于。
幸好这没什么大不了的;我们得到的循环很好。
我写了一个 C 程序,只是 read/write 一个大数组。我用命令 gcc -O0 program.c -o program
编译了程序 出于好奇,我用 objdump -S
命令反汇编了 C 程序。
read_array
和write_array
函数的代码和汇编附在本题末尾
我正在尝试解释 gcc 是如何编译该函数的。我使用 //
添加我的评论和问题
取write_array()
函数的汇编代码开头一段
4008c1: 48 89 7d e8 mov %rdi,-0x18(%rbp) // this is the first parameter of the fuction
4008c5: 48 89 75 e0 mov %rsi,-0x20(%rbp) // this is the second parameter of the fuction
4008c9: c6 45 ff 01 movb [=10=]x1,-0x1(%rbp) // comparing with the source code, I think this is the `char tmp` variable
4008cd: c7 45 f8 00 00 00 00 movl [=10=]x0,-0x8(%rbp) // this should be the `int i` variable.
我不明白的是:
1) char tmp
显然是在write_array
函数中afterint i
定义的。为什么 gcc 对这两个局部变量的内存位置重新排序?
2) 从偏移量来看,int i
在-0x8(%rbp)
,char tmp
在-0x1(%rbp)
,这表明变量int i
需要7 字节?这很奇怪,因为 int i
在 x86-64 机器上应该是 4 个字节。不是吗?我的猜测是 gcc 试图做一些调整?
3) 我发现 gcc 优化选项非常 有趣 。有什么好的 documents/book 可以解释 gcc 是如何工作的吗? (第三个问题可能跑题了,如果你这么认为,请忽略。我只是想看看有没有捷径可以学习gcc用于编译的底层机制。:-))
下面是一段函数代码:
#define CACHE_LINE_SIZE 64
static inline void
read_array(char* array, long size)
{
int i;
char tmp;
for ( i = 0; i < size; i+= CACHE_LINE_SIZE )
{
tmp = array[i];
}
return;
}
static inline void
write_array(char* array, long size)
{
int i;
char tmp = 1;
for ( i = 0; i < size; i+= CACHE_LINE_SIZE )
{
array[i] = tmp;
}
return;
}
下面是 write_array
的一段反汇编代码,来自 gcc -O0:
00000000004008bd <write_array>:
4008bd: 55 push %rbp
4008be: 48 89 e5 mov %rsp,%rbp
4008c1: 48 89 7d e8 mov %rdi,-0x18(%rbp)
4008c5: 48 89 75 e0 mov %rsi,-0x20(%rbp)
4008c9: c6 45 ff 01 movb [=12=]x1,-0x1(%rbp)
4008cd: c7 45 f8 00 00 00 00 movl [=12=]x0,-0x8(%rbp)
4008d4: eb 13 jmp 4008e9 <write_array+0x2c>
4008d6: 8b 45 f8 mov -0x8(%rbp),%eax
4008d9: 48 98 cltq
4008db: 48 03 45 e8 add -0x18(%rbp),%rax
4008df: 0f b6 55 ff movzbl -0x1(%rbp),%edx
4008e3: 88 10 mov %dl,(%rax)
4008e5: 83 45 f8 40 addl [=12=]x40,-0x8(%rbp)
4008e9: 8b 45 f8 mov -0x8(%rbp),%eax
4008ec: 48 98 cltq
4008ee: 48 3b 45 e0 cmp -0x20(%rbp),%rax
4008f2: 7c e2 jl 4008d6 <write_array+0x19>
4008f4: 5d pop %rbp
4008f5: c3 retq
对于保存在栈中的局部变量,地址顺序取决于栈的增长方向。您可以参考Does stack grow upward or downward?了解更多信息。
This is quite weird because int i should be 4 bytes on x86-64 machine. Isn't it?
如果我没记错的话,x86-64 机器上 int 的大小是 8。你可以通过编写一个测试应用程序来打印来确认它 sizeof(int)
。
即使在 -O0
,gcc 也不会发出 static inline
函数的定义,除非有调用者。在那种情况下,它实际上并没有内联:而是发出一个独立的定义。所以我猜你的反汇编是从那个开始的。
您使用的是非常旧的 gcc 版本吗? gcc 4.6.4 将变量按此顺序放入堆栈,但 4.7.3 及更高版本使用其他顺序:
movb , -5(%rbp) #, tmp
movl [=10=], -4(%rbp) #, i
在您的 asm 中,它们按初始化顺序而不是声明顺序存储,但我认为这只是偶然,因为 gcc 4.7 改变了顺序。此外,添加像 int i=1;
这样的初始化程序不会改变分配顺序,因此完全破坏了该理论。
记住 gcc is designed around a series of transformations from source to asm, so -O0
doesn't mean "no optimization"。您应该将 -O0
视为遗漏了 -O3
通常会做的一些事情。没有选项试图从源代码到 asm 进行尽可能逐字的翻译。
一旦 gcc 决定为它们分配 space 的顺序:
位于
rbp-1
的char
:这是第一个可以容纳char
的位置。如果还有另一个char
需要存储,它可以放在rbp-2
.int
atrbp-8
:由于从rbp-1
到rbp-4
的4个字节不是空闲的,下一个可用的自然对齐位置是rbp-8
.
或者对于 gcc 4.7 和更新版本,-4 是 int 的第一个可用位置,-5 是它下面的下一个字节。
回复:space 储蓄:
的确,将字符置于 -5 会产生最低接触地址 %rsp-5
,而不是 %rsp-8
,但这不会保存任何内容。
堆栈指针在 AMD64 SysV ABI 中是 16B 对齐的。 (从技术上讲,%rsp+8
(堆栈参数的开始)在函数入口处对齐,在你推送任何东西之前。)%rbp-8
触摸新页面或缓存行的唯一方法 %rbp-5
不会是堆栈小于 4B 对齐。这是极不可能的,即使在 32 位代码中也是如此。
就函数“分配”或“拥有”多少堆栈而言:在 AMD64 SysV ABI 中,函数“拥有”下方 128B 的红色区域 %rsp
(That size was chosen because a one-byte displacement can go up to -128
) .信号处理程序和 user-space 堆栈的任何其他异步用户将避免破坏红色区域,这就是为什么函数可以写入 %rsp
以下的内存而不递减 %rsp
的原因。所以从这个角度来看,我们使用多少红色区域并不重要;信号处理程序 运行 出栈的几率不受影响。
在没有 redzone 的 32 位代码中,对于任何一个命令,gcc 都在 sub , %esp
的堆栈上保留 space。 (尝试在 godbolt 上使用 -m32
)。所以还是那句话,不管我们用5字节还是8字节都无所谓,因为我们是以16为单位预留的。
当有许多 char
和 int
变量时,gcc 将 char
打包成 4B 组,而不是将 space 丢失到碎片中,即使声明混合在一起:
void many_vars(void) {
char tmp = 1; int i=1;
char t2 = 2; int i2 = 2;
char t3 = 3; int i3 = 3;
char t4 = 4;
}
with gcc 4.6.4 -O0 -fverbose-asm
,这有助于标记哪个存储是哪个变量,这就是编译器 asm 输出优于反汇编的原因:
pushq %rbp #
movq %rsp, %rbp #,
movb , -4(%rbp) #, tmp
movl , -16(%rbp) #, i
movb , -3(%rbp) #, t2
movl , -12(%rbp) #, i2
movb , -2(%rbp) #, t3
movl , -8(%rbp) #, i3
movb , -1(%rbp) #, t4
popq %rbp #
ret
我认为变量在 -O0
.
我制作了您的 read_array
函数的一个版本,可以优化:
// assumes that size is non-zero. Use a while() instead of do{}while() if you want extra code to check for that case.
void read_array_good(const char* array, size_t size) {
const volatile char *vp = array;
do {
(void) *vp; // this counts as accessing the volatile memory, with gcc/clang at least
vp += CACHE_LINE_SIZE/sizeof(vp[0]);
} while (vp < array+size);
}
Compiles to the following, with gcc 5.3 -O3 -march=haswell:
addq %rdi, %rsi # array, D.2434
.L11:
movzbl (%rdi), %eax # MEM[(const char *)array_1], D.2433
addq , %rdi #, array
cmpq %rsi, %rdi # D.2434, array
jb .L11 #,
ret
将表达式转换为 void 是告诉编译器使用了一个值的规范方法。例如要抑制未使用变量警告,您可以编写 (void)my_unused_var;
.
对于 gcc 和 clang,使用 volatile
指针解除引用确实会生成内存访问,不需要 tmp 变量。 C 标准对于什么构成对 volatile
的访问的内容非常不明确,因此这可能不是完全可移植的。另一种方法是 xor
您读入累加器的值,然后将其存储到全局变量中。只要你不使用全程序优化,编译器就不知道什么都没有读取全局,所以它无法优化计算。
有关第二种技术的示例,请参阅 the vmtouch
source code。 (它实际上为累加器使用了一个全局变量,这使得代码很笨重。当然,这并不重要,因为它涉及页面,而不仅仅是缓存行,所以它很快就会成为 TLB 未命中和页面错误的瓶颈,即使是内存读取 -在循环携带的依赖链中修改写入。)
我尝试编写了一些 gcc 或 clang 将编译为没有序言的函数(假设 size
最初是非零的),但失败了。 GCC 总是希望 add rsi,rdi
用于 cmp/jcc
循环条件,即使 -march=haswell
其中 sub rsi,64
/jae
可以像 cmp/jcc
一样进行宏融合].但总的来说,在 AMD 上,GCC 在循环中的微指令更少。
read_array_handtuned_haswell:
.L0
movzx eax, byte [rdi] ; overwrite the full RAX to avoid any partial-register false deps from writing AL
add rdi, 64
sub rsi, 64
jae .L0 ; or ja, depending on what semantics you want
ret
Godbolt Compiler Explorer link with all my attempts and trial versions
如果循环终止条件是je
,在像do { ... } while( size -= CL_SIZE );
这样的循环中,我可以得到类似的结果,但我似乎无法说服gcc在减法时捕获无符号借用。它想减去然后 cmp -64/jb
来检测下溢。这是 not that hard to get compilers to check the carry flag after an add to detect carry :/
让编译器创建一个 4-insn 循环也很容易,但并非没有序言。例如计算结束指针(数组+大小)并递增指针直到它大于或等于。
幸好这没什么大不了的;我们得到的循环很好。