从百草园到三味书屋

0x00 引言

最近看论文看到使用SIMD指令加速,从指令集到AT&T汇编再到linux系统调用一路折腾,这里就做一个小小的记录。

0x01 AT&T汇编

参考https://blog.csdn.net/qq_53144843/article/details/120346586

0x02 IEEE754

https://blog.csdn.net/gao_zhennan/article/details/120717424#t8这篇文章讲得太好了

0x03 内联汇编

参考https://zhuanlan.zhihu.com/p/578286784

1
2
3
4
5
__asm__("汇编语句"
:输出寄存器
:输入寄存器
:会被修改的寄存器
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
通用寄存器:
"a" 将输入变量放入eax
"b" 将输入变量放入ebx
"c" 将输入变量放入ecx
"d" 将输入变量放入edx
"s" 将输入变量放入esi
"d" 将输入变量放入edi
"q" 将输入变量放入eax,ebx,ecx,edx中的一个
"r" 将输入变量放入通用寄存器,也就是eax,ebx,ecx,edx,esi,edi中的一个
"A" 把eax和edx合成一个64 位的寄存器(use long longs)

内存:

"m" 内存变量
"o" 操作数为内存变量,但是其寻址方式是偏移量类型,也即是基址寻址,或者是基址加变址寻址
"V" 操作数为内存变量,但寻址方式不是偏移量类型
" " 操作数为内存变量,但寻址方式为自动增量

"g" 将输入变量放入eax,ebx,ecx,edx中的一个,或者作为内存变量 "X" 操作数可以是任何类型

立即数:
"I" 0-31之间的立即数(用于32位移位指令)
"J" 0-63之间的立即数(用于64位移位指令)
"N" 0-255之间的立即数(用于out指令)
"i" 立即数
"n" 立即数

匹配:
" 0-9 " 表示用它限制的操作数与某个指定的操作数匹配,去描述"%1"操作
数,那么"%1"引用的其实就是"%0"操作数,注意作为限定符字母的0-9 与指令中
的"%0"-"%9"的区别,前者描述操作数,后者代表操作数。

& 该输出操作数不能使用过和输入操作数相同的寄存器

操作数类型 :
"=" 操作数在指令中是只写的(输出操作数)
"+" 操作数在指令中是读写类型的(输入输出操作数)

浮点数:
"f" 浮点寄存器
"t" 第一个浮点寄存器
"u" 第二个浮点寄存器
"G" 标准的80387浮点常数
% 该操作数可以和下一个操作数交换位置

# 部分注释,从该字符到其后的逗号之间所有字母被忽略
* 表示如果选用寄存器,则其后的字母被忽略

举个例子:

1
2
3
4
5
6
7
8
9
int main() {
int input = 10;
int output;
asm volatile("nop"
:"=c"(output)
:"c"(input)
);
printf("output is %d", output);
}

nop代表没有汇编操作,"=c"(output)表示最后会将寄存器ecx的值给output,"c"(input)在最开始会将input的值给ecx。

0x04 System call和System interrupt

简单来说,这两者均是为了用户态调用内核态的功能(比如文件读写等)。

使用C语言进行syscall

参考https://www.baeldung.com/cs/system-call-vs-system-interrupt

1
2
3
4
5
#include <sys/syscall.h>
int main() {
syscall(SYS_write, 1, "hello world\n", 12);
return 0;
}

先来看看头文件的内容,<sys/syscall.h>文件在/usr/include/x86_64-linux-gnu/sys/syscall.h下,内如如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef _SYSCALL_H
#define _SYSCALL_H 1

/* This file should list the numbers of the system calls the system knows.
But instead of duplicating this we use the information available
from the kernel sources. */
#include <asm/unistd.h>

/* The Linux kernel header file defines macros __NR_*, but some
programs expect the traditional form SYS_*. <bits/syscall.h>
defines SYS_* macros for __NR_* macros of known names. */
#include <bits/syscall.h>

#endif

再来看看<bits/syscall.h>中的内容,vim /usr/include/x86_64-linux-gnu/bits/syscall.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifdef __NR_waitid
# define SYS_waitid __NR_waitid
#endif

#ifdef __NR_waitpid
# define SYS_waitpid __NR_waitpid
#endif

#ifdef __NR_write
# define SYS_write __NR_write
#endif

#ifdef __NR_writev
# define SYS_writev __NR_writev
#endif

可以看到SYS_write就是__NR_write

再来看看<asm/unistd.h>中的内容,vim /usr/include/x86_64-linux-gnu/asm/unistd.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#ifndef _ASM_X86_UNISTD_H
#define _ASM_X86_UNISTD_H

/*
* x32 syscall flag bit. Some user programs expect syscall NR macros
* and __X32_SYSCALL_BIT to have type int, even though syscall numbers
* are, for practical purposes, unsigned long.
*
* Fortunately, expressions like (nr & ~__X32_SYSCALL_BIT) do the right
* thing regardless.
*/
#define __X32_SYSCALL_BIT 0x40000000

# ifdef __i386__
# include <asm/unistd_32.h>
# elif defined(__ILP32__)
# include <asm/unistd_x32.h>
# else
# include <asm/unistd_64.h>
# endif

#endif /* _ASM_X86_UNISTD_H */

找到了我们最后想要的<asm/unistd_64.h>vim /usr/include/x86_64-linux-gnu/asm/unistd_64.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#ifndef _ASM_X86_UNISTD_64_H
#define _ASM_X86_UNISTD_64_H 1

#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
#define __NR_stat 4
#define __NR_fstat 5
#define __NR_lstat 6
#define __NR_poll 7
#define __NR_lseek 8
#define __NR_mmap 9
#define __NR_mprotect 10
#define __NR_munmap 11
#define __NR_brk 12
#define __NR_rt_sigaction 13
#define __NR_rt_sigprocmask 14
#define __NR_rt_sigreturn 15
#define __NR_ioctl 16
#define __NR_pread64 17
#define __NR_pwrite64 18
#define __NR_readv 19
#define __NR_writev 20
...
#define __NR_exit 60

下面用一张图简单梳理一下:

syscall.h

回到最开始的程序

1
2
3
4
5
#include <sys/syscall.h>
int main() {
syscall(SYS_write, 1, "hello world\n", 12);
return 0;
}

syscall的原型为long syscall(long number, ...);,通过SYS_write调用sys_write,其函数原型为long sys_write(unsigned int fd, const char __user *buf, size_t count);

其作用就是通过syscall调用sys_write()功能,并写入stdout(1)中,stdin为0,stderr为2,内容为"hello world",长度为12.

使用汇编进行syscall

参考https://blog.csdn.net/chuck_huang/article/details/79922595

AMD64 ABI convention

ABI_reg_usage
enter image description here

System V ABI规定了对64位程序的接口,也规定了函数参数的传递规则,根据此规则,用调用sys_write(这里参数的第一第二的顺序是针对sys_write而言的,syscall要调用哪一个函数由rax决定,由#define __NR_write 1知道要调用sys_write应该将rax设置为1),所以文件描述符1需要加载到rdi寄存器,“hello world” 字符串的地址需要加载到 %rsi,字符串的长度加载到 rdx。

那么我们的AT&T汇编程序hello.s如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    .section .data
message:
.ascii "hello world!\n"
length = . - message

.section .text
.global _start # must be declared for linker
_start:
movq $1, %rax # 'write' syscall number
movq $1, %rdi # file descriptor, stdout
lea message(%rip), %rsi # relative addressing string message
movq $length, %rdx
syscall

movq $60, %rax # 'exit' syscall number
xor %rdi, %rdi # set rdi to zero, first parameter of sys_exit
syscall

其中 .section .data 和 .section .text 定义个数据段 和代码段。

message 只是一个label 方便我们来引用 hello world 字符串。

length = . - message 用来计算字符串的长度。. 用来表示当前的地址

_start 是程序的入口

进行汇编和链接,并运行:

1
2
3
4
5
6
singheart@amd:~/project/assembly$ as -o hello.o hello.s
singheart@amd:~/project/assembly$ ld -o hello hello.o
singheart@amd:~/project/assembly$ ./hello
hello world!
singheart@amd:~/project/assembly$

使用汇编进行interrupt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#hello.s
.data # 数据段声明
msg : .string "hello,world!\n" # 要输出的字符串
len = . - msg # 字串长度
.text # 代码段声明
.global _start # 指定入口函数

_start: # 在屏幕上显示一个字符串
movl $len, %edx # 参数三:字符串长度
movl $msg, %ecx # 参数二:要显示的字符串
movl $1, %ebx # 参数一:文件描述符(stdout)
movl $4, %eax # 系统调用号(sys_write)
int $0x80 # 调用内核功能
# 退出程序
movl $0, %ebx # 参数一:退出代码
movl $1, %eax # 系统调用号(sys_exit)
int $0x80 # 调用内核功能
#end

这里使用int $0x80中断,不太了解详细信息,不做过多解释。

使用汇编进行printf

1
2
3
4
5
6
7
8
9
10
11
12
13
.section .data
msg:
.asciz "Printf In Assembly!!\n"

.section .text
.globl main
main:
leaq msg(%rip), %rdi
xor %al, %al
call printf@plt

xor %edi, %edi
call exit@plt

@plt代表使用了动态链接库,需要从procedure linkage table中拿到printf这个符号的地址,然后才能解析调用。

还是根据AMD64 ABI convention,传递函数参数并进行函数调用

1
2
3
singheart@amd:~/project/assembly$ gcc -o print print.s 
singheart@amd:~/project/assembly$ ./print
Printf In Assembly!!

0x05 SIMD指令

参考自https://en.wikipedia.org/wiki/CPUID

按照SIMD出现的时间,由以下几类SIMD指令

我们可以通过cpuid指令来查看是否支持各类型的SIMD指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
.section .data
mmxstring: .asciz "支持mmx指令集\n"
ssestring: .asciz "支持sse指令集\n"
sse2string: .asciz "支持sse2指令集\n"
sse3string: .asciz "支持sse3指令集\n"
.section .text
.global main
main:
movl $1, %eax
cpuid

mmxop:
test $0x800000, %edx
jz sseop
leaq mmxstring(%rip), %rdi
xor %al, %al
call printf
sseop:
test $0x2000000, %edx
jz sse2op
leaq ssestring(%rip), %rdi
xor %al, %al
call printf
sse2op:
test $0x4000000, %edx
jz sse3op
leaq sse2string(%rip), %rdi
xor %al, %al
call printf
sse3op:
test $0x01, %ecx
jz end
leaq sse3string(%rip), %rdi
xor %al, %al
call printf
end:
xor %edi, %edi
call exit

SSE指令

参考自https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions

SSE全称为Streaming SIMD Extensions,主要是为了单精度浮点数的运算而生。

Floating-point instructions

  • Memory-to-register/register-to-memory/register-to-register data movement
    • Scalar – MOVSS
    • Packed – MOVAPS, MOVUPS, MOVLPS, MOVHPS, MOVLHPS, MOVHLPS, MOVMSKPS
  • Arithmetic
    • Scalar – ADDSS, SUBSS, MULSS, DIVSS, RCPSS, SQRTSS, MAXSS, MINSS, RSQRTSS
    • Packed – ADDPS, SUBPS, MULPS, DIVPS, RCPPS, SQRTPS, MAXPS, MINPS, RSQRTPS
  • Compare
    • Scalar – CMPSS, COMISS, UCOMISS
    • Packed – CMPPS
  • Data shuffle and unpacking
    • Packed – SHUFPS, UNPCKHPS, UNPCKLPS
  • Data-type conversion
    • Scalar – CVTSI2SS, CVTSS2SI, CVTTSS2SI
    • Packed – CVTPI2PS, CVTPS2PI, CVTTPS2PI
  • Bitwise logical operations
    • Packed – ANDPS, ORPS, XORPS, ANDNPS

关于scalar和packed的区别参考https://zhuanlan.zhihu.com/p/556131141

movups指令,这条指令名称上分为四个部分:

  1. mov,表示数据移动,操作双方可以是内存也可以是寄存器。

  2. u,表示 unaligned,内存未对齐。如果是a,表示 aligned,内存已对齐。

  3. p,表示 packed,打包数据,会对128位所有数据执行操作。如果是s,则表示 scalar,标量数据,仅对128位内第一个数执行操作。

  4. s,表示 single precision floating point,将数据视为32位单精度浮点数,一组4个。如果是d,表示 double precision floating point,将数据视为64位双精度浮点,一组两个。

从内存中向寄存器加载数据时,必须区分数据的对齐与否。SSE指令要求数据按16字节对齐,未对齐数据必须使用movups,已对齐数据可以任意使用movups或者movaps。对齐的数据需要按照下面这样进行声明:

1
2
// C++ 11 alignas
alignas(16) float a[4] = { 1,2,3,4 };

对非对齐的数据使用movaps,会导致程序崩溃。理论上movups相比movaps性能会差一些,但在较新的CPU上性能差异已经基本可以忽视。

现在来看一个例子,有两个float4数组,需要将他们分别相乘,结果存入另一个数组,写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.section .data
format: .asciz "%f\n"
a: .float 6, 7, 8, 9
b: .float 2, 3, 4, 5
res: .float 8, 1, 2, 5

.section .text
.globl main
main:
push %rbp
leaq format(%rip), %rdi
movq $1, %rax
movups a(%rip), %xmm0
movups b(%rip), %xmm1
mulps %xmm1, %xmm0
movups %xmm0, res(%rip)
cvtss2sd res+4(%rip), %xmm0
call printf@plt

xor %eax, %eax
call exit@plt

封装的函数库

汇编写起来还是太麻烦了,我们可以直接使用封装好的库进行调用,下面是这些头文件。

1
2
3
4
5
6
7
8
9
10
#include <mmintrin.h>     // MMX
#include <xmmintrin.h> // SSE
#include <emmintrin.h> // SSE2
#include <pmmintrin.h> // SSE3
#include <tmmintrin.h> // SSSE3
#include <smmintrin.h> // SSE4.1
#include <nmmintrin.h> // SSE4.2
#include <wmmintrin.h> // AES
#include <immintrin.h> // AVX
#include <intrin.h>

需要注意的是,printf("%f", f_number)中%f对应的是double,当传递的类型为float的时候会提升成double。

我们使用c语言对上面的汇编进行改写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <xmmintrin.h>
#include <stdio.h>

int main() {
float a[4] = {1, 2, 3, 4};
float b[4] = {5, 6, 7, 8};
float res[4];

__m128 A = _mm_loadu_ps(a);
__m128 B = _mm_loadu_ps(b);
__m128 RES = _mm_mul_ps(A, B);
_mm_storeu_ps(res, RES);

for (int i = 0; i < 4; i++) {
printf("%f ", res[i]);
}
return 0;
}

同样的,我们来看看所使用的指令

  1. _mm,表示这是一个64位/128位的指令,_mm256和_mm512则表示是256位或是512位的指令
  2. _loadu,表示unaligen的load指令,不带u后缀的为aligen版本
  3. _ps,同上面汇编指令,还可以是_pd,_ss,_sd

具体的指令可以查看intel的手册:https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html