汇编小引
引入汇编的基础介绍,熟悉的人不用看
对于下面一个,简单的a+b的代码
int add_a(int a) {
a = a + 1;
return a;
}
int main() {
return add_a(8);
}
其汇编是:
add_a:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi // 读取a
add DWORD PTR [rbp-4], 1 // ++
mov eax, DWORD PTR [rbp-4] // 存回去
pop rbp
ret
main:
push rbp
mov rbp, rsp
mov edi, 8
call add_a
pop rbp
ret
注意到两个函数开始的命令都是两个有关rbp
寄存器的命令。rbp
寄存器一开始指向的是当前栈帧的基地址,也就是调用main
函数的栈帧的地址,我们将其压入到当前栈帧的栈顶————这意味着main
函数栈帧的上面还存储了上一个栈帧的基地址,这样我们才能知道main函数如何返回。然后我们将当前的栈顶指针(rsp)移到栈基地址(rbp)里面,这样我们就确立了main函数栈帧的基地址。
然后看到a++的函数:a++先是读取a到edi(Extended Destination Index,扩展目标索引寄存器)里,然后++之,最后存回去。
由此可知,即使是最简单的++,电脑操作一个数的流程是:读取,计算,存回。
restrict说明
使用restrict修饰的指针,代表像编译器保证,当前作用域内不会再有另一个指针指向同一个内存地址(怎么有点c++里unique_ptr之类的感觉)。这样的好处是,在编译的时候,可以提前将这个值加载进入CPU寄存器里面,而不是反复从内存中读取。
下面是在这个网站上测试的结果。编译选项加了-O2
优化,gcc14.1。这是个很简单的代码,把c加到a+b上。
int square(int* a, int* b, int* c) {
*a += *c;
*b += *c;
}
那么,汇编是怎么样的呢?
square:
// 读取c
mov eax, DWORD PTR [rdx]
//读取a
add DWORD PTR [rdi], eax
// 读取c
mov eax, DWORD PTR [rdx]
// 读取b
add DWORD PTR [rsi], eax
ret
我们看到,变量c被读取到寄存器读取了两次,即mov eax, DWORD PTR [rdx]
这一段。但是我们知道,完全没这个必要,让人类写肯定不会这么干。这里是使用restrict
修饰的代码:
int square(int* restrict a, int* restrict b, int* restrict c) {
*a += *c;
*b += *c;
}
对应的汇编是:
square:
// 读取c
mov eax, DWORD PTR [rdx]
// 加a
add DWORD PTR [rdi], eax
// 加b
add DWORD PTR [rsi], eax
ret
哇,这不就是我们理想的代码吗?或者说,这才是这一段代码的真正逻辑啊!我一开始有一个疑问,这都是O2级别优化了,编译器难道不知道还有没有指针指向这个内存空间吗?我后来一想,编译器虽然知道编译的时候没有重复指针指向这个空间,但是编译器不能知道,运行之后会不会有代码改变某个指针的值,导致重复指向!所以,这是我们人类需要向其保证的。另外,如果你知道多线程操作不加锁的变量为什么会错误,你就能理解这个保证的意义了,这两个问题是一个道理。
那么,优化的原理也十分显然了,因为减少了内存读取的操作,将加速运算。我想,这样的关键字在计算密集型函数内应该十分有用,有兴趣的读者可以自行编写程序测试这样的优化到底有多少效果。
unlikely和likely
此二者经常出现在linux内核代码中,是何意思?请看代码:
# define likely(x) __builtin_expect(!!(x), 1)
# define unlikely(x) __builtin_expect(!!(x), 0)
__builtin_expect(foo, s)
是一个编译器优化关键字,代表表达式foo的值大概率是s,其中,s应当是一个const
,例子中!!(x)
是为了让其计算结果确实是一个布尔值,不论之前是什么,例如之前是999,反转再反转就是布尔值1了。
所以,likely(x)
的意思就是x大概率成立,反之就是x大概率不成立。此宏意义何在呢?用来辅助编译器优化分支流程。例如在大流量的网络接口中,好的封包显然占大多数,那么对于异常包的处理就并不总是被触发。我们知道if是一个分支命令,翻译成汇编(不优化的话)会有jmp命令存在,而这并不利于CPU流水线运算(不是jmp不利于,而是jmp之后的代码有可能不利于提高缓存命中率,不能很好的使用寄存器)。使用likely(x)
宏告诉编译器,你把我这个大概率执行的代码放在正下方,把小概率执行的代码使用jmp命令跳转,从而提升执行速度。
if (likely(packet_is_valid(packet))) {
// 快速路径:处理有效的网络包
} else {
// 慢速路径:处理无效或损坏的网络包
}
这个宏在内核中处处可见,用于异常值的处理。内核中处理异常情况全都不是直接写(foo == false)
,而是使用unlikely
明确指出当前情况大概率不发生,也就是告诉编译器这里是异常处理,你塞一边去,别影响主要流程的执行效率。我其实编写了示例代码进行测试,发现在逻辑简单的时候,这个宏的作用并不能很好的触发,不过我记录了下面的现象,编译器是gcc,开了O2:
# define likely(x) __builtin_expect(!!(x), 1)
# define unlikely(x) __builtin_expect(!!(x), 0)
int square(int num) {
int t = 0;
// if (num == 777){
if (unlikely(num)){
t = 999;
}
else
t = 888;
return t;
}
不使用unlikely
:
square:
cmp edi, 777
mov edx, 999
mov eax, 888
cmove eax, edx
ret
使用unlikely
:
square:
cmp edi, 1
sbb eax, eax
and eax, -111
add eax, 999
ret
第一段代码使用的cmove
是指,如果比较结果为相等 (edi == 777)
,则将 edx
的值(999)
移动到 eax
。是条件执行指令,有点类似于c++里面的三目表达式。不过unlikely
是使用了位运算。我多次测试发现使用unlikely
指定的分支语句,编译器将会更多的使用位运算,甚至是尽可能的使用位运算。可是AND
和ADD
都是一个指令周期的,这是为啥呢?
宏粘连符##
这个很简单,直接上代码就看懂了,假设我有这样的代码:
#include <stdio.h>
#define __cmp_op_gt >
#define __cmp_op_lt <
#define __cmp_op_ge >=
#define __cmp_op_le <=
#define __cmp(op, x, y) ((x) __cmp_op_##op (y) ? (x) : (y))
int main() {
int a = 10, b = 20;
int max = __cmp(gt, a, b);
int min = __cmp(lt, a, b);
printf("max: %d\n", max); // 输出: max: 20
printf("min: %d\n", min); // 输出: min: 10
return 0;
}
注意到了吗,__cmp(gt, a, b)
可以传入运算符,而((x) __cmp_op_##op (y) ? (x) : (y))
里面,op
作为传入的参数,粘在了__cmp_op_
的后面,例如这里,就粘连成了__cmp_op_gt
,也就是>
。
可是,我的疑问是,为什么不直接定义#define cmp_gt(a, b) (a > b ? a : b)
,这样不也是一样的?对此,GPT给出的答案是,这样有利于维护,因为你只需要维护#define __cmp(op, x, y) ((x) __cmp_op_##op (y) ? (x) : (y))
一处就可以了,换句话说,这里仅仅执行比较,而不是在四个地方执行比较!听起来很有道理的样子。不可否认,这样的思想确实是很高明。