使用电脑端体验更佳

如何检测CPU的simd指令集?

发布于:1787762443959 | 分类: 技术 | 浏览: 19

# 检测CPU的simd指令集 > 在X86下用C++完成 如何检测CPU支持的指令集,这是个有趣的问题。 起因是我在写一个用simd加速向量计算的库。 我找了很久,发现了一个C函数,叫做`void __cpuid(int[4], int);`,在头文件`<intrin.h>`中 这里我觉得有必要引入一个概念,叫intrinsic function,它指的是一类可以直接从C函数翻译成对应汇编指令的函数。相比汇编来说,使用者不用去管寄存器,调用约定,只要把参数传给Intrinsic function就行了。 `__cpuid`函数就是这么一个intrinsic,其对应的汇编指令为`CPUID`。 `CPUID`的用法是:先往EAX中输入一个参数,然后CPU会往四个寄存器`EAX,EBX,ECX,EDX`返回对应的信息。 `__cpuid`函数的第一个参数`int[4]`**代表了四个寄存器**,`EAX,EBX,ECX,EDX`,在这里是作为返回值用的,第二个参数`int`代表了EAX,这里是作为输入参数。 对于输入什么,返回什么,什么代表什么,我找了参考的资料 : [cpuid](https://www.felixcloutier.com/x86/cpuid) 有个最简单的把戏:往eax里输入00H,然后就会按EBX,EDX,ECX的顺序返回CPU的制造商 ```c int rgstr[4]; memset(rgstr, 0x00000000, 4 * sizeof(int)); __cpuid(rgstr, 0x00000000); char vendor[17]; memset(vendor, '\0', 14); memcpy(vendor, rgstr + 1, sizeof(int)); memcpy(vendor + sizeof(int), rgstr + 3, sizeof(int)); memcpy(vendor + 2 * sizeof(int), rgstr + 2, sizeof(int)); ``` 如果输出字符数组vendor就能看到结果: GenuineIntel 当然这是Intel cpu的输出 对于AMD会输出 : AuthenticAMD ## 检测SIMD指令集 首先,把EAX输入01H 也就是: ```c __cpuid(rgstr, 0x00000001); ``` 然后根据参考资料上的[figure3-7](https://www.felixcloutier.com/x86/cpuid#fig-3-7)可以知道哪个寄存器的哪一位代表了什么。 比如说从表里可知,ECX的第0位代表SSE3的支持 那么如果ECX & 0x00000001 == 1 => CPU支持SSE3指令集。 所以可以这么写代码 ```cxx std::bitset<32> bitInfo(rgstr[3]); if (bitInfo[0]) { SSE3Support = true; } ``` 所以对着表去写代码吧^_^ <br> ## 坑 其实吧...如果当你想检测AVX2的时候就会发现不对劲。 [Table3-8](https://www.felixcloutier.com/x86/cpuid#tbl-3-8)中说只要EAX输入了07H就能在EBX里拿到一堆信息! 然后我兴致勃勃的写下了代码,震惊的发现 -- 什么,我的CPU不支持AVX2 o(≧口≦)o (╯°□°)╯︵ ┻━┻ ╰(‵□′)╯ ----- 好吧其实是这样的 它边上有一行小字-> Structured Extended Feature Flags Enumeration Leaf (Output depends on ECX input value) 意思是这东西还得根据ECX的值来返回不同的值。 但问题是__cpuid只有两个输入,怎么办? 然后我发现有那么个函数叫做 ```c void __cpuidex(int[4], int, int); ``` 这个函数前两个参数与cpuid无异,第三个参数就是ECX了。 顺带一提,当ECX=00H时,这个函数和__cpuid的功能是一样的。 ------------------------ # 如何在gnuc中写汇编 当我妄图把写的代码移植到linux-gcc上时发现 "intrin.h"不存在 那么函数cpuid也不存在了,哭 > PS: 其实还是有的,不过在 "cpuid.h" 里,但是man cpuid只介绍了这条汇编和一些设备相关的东西..... 那该怎么办呢? 内联汇编啊o(〃^▽^〃)o 于是踏上了编译器拓展这条不归路... ## 0x01 C++存在`asm`关键字来允许程序中嵌入汇编代码。 GCC种的内联汇编分成两种,第一种是基础汇编(Basic Asm),这种形式的内联汇编不能使用GCC拓展的操作数(Operand),第二种呢就是拓展汇编(Extended Asm),能用GCC拓展的操作数... GCC官方的建议是使用拓展汇编,因为这样性能更好 > Using extended asm typically produces smaller, safer, and more efficient code, and in most cases it is a better solution than basic asm 但是拓展汇编只能在函数中使用(而且不能在裸函数中使用) ## 0x02 不论使用哪种汇编,其格式都是 ```c++ asm asm-qualifiers ( ... ) ``` 对于基础汇编,直接把“...”换为汇编指令的字符串就行啦 扩展汇编么...会多一堆东西..后面再说 asm-qualifiers,就是汇编的限定符,分为三种 - volatile - inline - goto GCC(邪恶的)优化器会尝试优化内联汇编,甚至可能把某段内联汇编移除,不想遭此待遇,那么就要加上`volatile` 来防止优化,如果你的asm中含有一些side effects的话,这点就非常重要(举个例子 ,对某些单片机初始化会要求对寄存器连续赋值,那么赋值的顺序就十分重要,不能颠倒。但GCC认为汇编就是用输入来产生输出的,为了快速得到输出,会对顺序,甚至语句进行修改)。并且基础汇编隐式的包含此限定符 >虽然优化器会对内联汇编进行操作,但是parser不会对内联汇编进行解析哦 使用`inline` 限定符,那么GCC会生成尽可能小的汇编代码。 `goto` 限定符只能对拓展汇编使用,提示编译器这段语句可能会跳转到外部的某处label,但是我认为这种用法过于邪恶所以只给出[参考](https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html#GotoLabels) ## 0x03 这里先写一段简简单单的基础汇编--两个int相加 C的写法是 ```c int funcAdd(int a,int b){ return a+b; } ``` 要把函数转换成汇编 此时参数a和参数b在哪个寄存器中呢....? 这时候就需要一点小小的知识♪(´▽`) 是什么控制着函数的参数传递呢?Calling Convention! 对32位下的C语言,其函数的默认Calling Convention是cdecl 这代表着其参数的传递方式是从右至左依次压栈(所以栈顶是第一个参数),返回值通过eax传递,而调用者负责清栈 于是可以写出这种代码来 ```c int funcAdd(int a,int b){ asm volatile( "movl %esp,%ebp\n\t" "movl 8(%ebp),%edx\n\t" "movl 12(%ebp),%eax\n\t" "addl %edx,%eax\n\t" ); } int main(){ int t = funcAdd(3, 5); printf("3 + 5 = %d\n",t); } ``` > output: 3 + 5 = 8 这段代码在gcc参数`-m32`下能编译通过且运行成功,否则会出现segmentation fault。 这里可能会有点疑惑: - 栈顶指针不是esp么,为什么我在用ebp? - 就算ebp代表栈顶,那此处为什么要有8Bytes的偏移量存在? 看一下外部调用的形式 打开反汇编发现在main函数中有这么一段 ```asm pushl $5 pushl $3 call funcAdd addl $8, %esp ``` 首先,这段代码会把两个参数从右向左依次压栈,接着执行 `call` 指令跳转到目标函数,在目标函数返回后(也就是内部调用了 `ret` )后平栈。 在 `Call`指令执行时,cpu会自动把ip压栈作为ret时的返回地址(所以ret时会pop ip),由于是32位系统,大小就是4Bytes。 GCC在不为人知的地方做了“努力”,函数的反汇编中会在开头发现两行神秘汇编 ```asm pushl %ebp movl %esp, %ebp ``` 这里GCC莫名奇妙为我们压栈了ebp,并且把esp赋值给了ebp。 其实是这样的,由于在程序运行中esp会自动增减(pop,push什么的),这样对于程序员来说不太好定位到目标。于是GCC会先保存栈基地址(也就是push ebp),然后把esp赋值给ebp,方便访问。 那么此时的栈内情况如下 ``` +----------+ |..........| |----------| 0x0000 | b | |----------| -0x0020 | a | |----------| -0x0040 | ip | |----------| -0x0080 | ebp | +----------+ -0x00C0 <--esp ``` >x86的栈是向下生长的哦 这下一切都说的通了 ## 0x04 上面展示的是基础汇编,这种写法很难与C的数据交互,很考验程序员的知识量,并且兼容性很成问题(从x86到x64都得大改一番 于是GCC提供了替代方案,拓展汇编。 拓展汇编的格式如下 ```asm asm asm-qualifiers ( AssemblerTemplate : OutputOperands : InputOperands : Clobbers : GotoLabels) ``` 这里括号内不再是汇编指令了,取而代之的是汇编模板,以及一大堆的参数列表,输出操作数(OutputOperands),输入操作数(InputOperands),Clobbers(这个我真的翻译不来),GOTO标签。 操作数(Operands)极大的方便了汇编与C语言数据的交互,格式如下 `[label] "constraints" (variable)` 每个操作数都拥有约束符`constraints`和其标签(可选) 在汇编模板中引用操作数的方法有两个,首先是利用操作数出现的位置N,然后用`%N`来引用,或者是利用标签,用`%[label]`来引用。 `"constraints"`通常代表着操作数在汇编中的存放方式,在这里列出有几个可能常用的 - '=' 代表这个操作数在asm结束后会被进行写入操作(只写),而编译器不会管它之前有没有值,'+'代表对操作数进行读和写,这两个符号被称为修改约束符 - 'm' 代表这个操作数是一个地址 - 'r' 代表这个操作数被存在寄存器里 - 'i' 代表操作数是立即数(需要编译期常量) - '0' '1' '2'... 用于输入操作数,代表其**constraints和存放的位置**与第N个操作数(输出操作数)相同 - 'a' 'b' 'c'... 代表这个操作数被存放在eax,ebx,ecx或者.... OutputOperands的约束必须以'='或者'+'开头 Clobbers含义比较复杂,大概就是:如果你的汇编修改了某些位置的值,但是没有在OutputOperands中被提到,那么就需要列在这里,用来提示编译器。 这里可以直接列出被修改的寄存器 (比如 "rax", "ebx",除了栈指针),如果内存被修改了那么应该填入"memory";"cc"如果修改了标志寄存器。 栗子时间\\( ̄︶ ̄*\\)) 改写以下之前的加法函数 ```c int funAdd(int a,int b){ int ret; asm volatile( "addl %1,%2\n\t" "movl %2,%0\n\t" : "=r"(ret) : "0"(a),"r"(b) :"cc" //对于 "addl %1,%2\n\t" 这行 --- %1 是 "=r"(ret) 这个参数, %2代表的是"0"(a) //"0"(a)代表对于%0和%1,他们在汇编中表表示的变量是同一个 //但是由于%0在OutputOperands中,而%1在InputOperands中, 结果就是在执行时会读入a的值,输出的时候会覆写ret的值 ); return ret; } ``` Very easy. 或者这样写 现在提升点难度,把结果输出到一个指针指向的内存中 ```c void funAddP(int a,int b,int* p){ asm volatile( "addl %1,%2\n\t" "movl %2,%[out]\n\t" : [out]"=m"(*p) : "r"(a),"r"(b) :"cc" ); } ``` 是不是觉得GCC的拓展也就那么回事,简简单单<(^-^)> ## 0x05 回到最开始的需求 --- 如何用汇编写一个cpuid函数? ```cpp void CpuChecker::m_cpuid(int registers[4], int eax, int ecx = 0) { #if defined (__GNUC__) asm volatile ("CPUID" : "=a" (registers[0]),"=b" (registers[1]), "=c" (registers[2]), "=d" (registers[3]) : "a" (eax), "c" (ecx) ); #else __cpuidex(registers, eax, ecx); #endif } ``` 首先,把输入分别放入eax,ecx,然后执行cpuid。 此时返回值应该被存在eax,ebx,ecx,edx,于是加上constraint来让值从目标寄存器写回数组里,收工。 ## Ref [6.47 How to Use Inline Assembly Language in C Code - gcc.gnu.org](https://gcc.gnu.org/onlinedocs/gcc/Using-Assembly-Language-with-C.html) 当然不是所有人都喜欢写一大串代码的.... 所以你可以用[微软写的示例](https://learn.microsoft.com/zh-cn/cpp/intrinsics/cpuid-cpuidex?view=msvc-170) (●'◡'●)


评论