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 #include <asm/unistd.h> #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 #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/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指令,这条指令名称上分为四个部分:
mov,表示数据移动,操作双方可以是内存也可以是寄存器。
u,表示 unaligned ,内存未对齐。如果是a,表示
aligned ,内存已对齐。
p,表示
packed ,打包数据,会对128位所有数据执行操作。如果是s,则表示
scalar ,标量数据,仅对128位内第一个数执行操作。
s,表示 single precision floating
point ,将数据视为32位单精度浮点数,一组4个。如果是d,表示
double precision floating
point ,将数据视为64位双精度浮点,一组两个。
从内存中向寄存器加载数据时,必须区分数据的对齐与否。SSE指令要求数据按16字节对齐,未对齐数据必须使用movups,已对齐数据可以任意使用movups或者movaps。对齐的数据需要按照下面这样进行声明:
1 2 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> #include <xmmintrin.h> #include <emmintrin.h> #include <pmmintrin.h> #include <tmmintrin.h> #include <smmintrin.h> #include <nmmintrin.h> #include <wmmintrin.h> #include <immintrin.h> #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 ; }
同样的,我们来看看所使用的指令
_mm,表示这是一个64位/128位的指令,_mm256和_mm512则表示是256位或是512位的指令
_loadu,表示unaligen的load指令,不带u后缀的为aligen版本
_ps,同上面汇编指令,还可以是_pd,_ss,_sd
具体的指令可以查看intel的手册:https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html