RTL的数值运算有很多需要注意的地方,特别是涉及到负数的时候,本章从最基础的数值存储(补码)开始,给出加减法、乘法和绝对值的实现方法,只涉及组合逻辑,不涉及时序逻辑,时序逻辑要多考虑一步,下次再说。
一、补码存在的意义
补码被设计出来就是为了做减法的,在硬件的世界里,减法也是按照加法做的,比如3-5其实可以理解为3+(-5),这个-5就是按照补码的形式存储的。
先理解一下模的概念,模是指一个计量系统的计数范围,我们知道数据的位宽是有限的,比如定义某个数的位宽为4bit,相当于定义了它的模就是16,它能表达的数据范围是0~15,也就是逢16进1,这也是十六进制的由来,虽然称为十六进制,但是你肯定看不到“16”这个数字,因为对于4bit的数据位宽来说,16其实是溢出的。
溢出的真实意思是,现有的位宽放不下实际的计算结果,还是以4bit举例,15是4bit能表示的最大数值,15+1=16,这个16用二进制表示出来是1_0000,对于硬件来说,只定义了4bit,多出来的那个1是不存在的,所以15+1的结果是0,在4bit的世界里,凡是超出实际位宽且能被16整除的部分都会被丢掉,留下的其实是余数,余数就是“模”,这像不像一个圆?只要位宽被定义出来,那么无论是加多少,结果都肯定在这个圆里面。
既然是个圆,加法被定义为正着走,那减法就可以被理解为逆着走,比如时钟的12个刻度,此刻是8点,要把时钟调到10点,可以顺时钟调2个刻度,也可以逆时钟调10个刻度,2+10=12,就是时钟的模,只要保证正向走+逆向走的结果等于模就是同一种操作。
那同样的,对于硬件的4bit位宽来讲,3+(-5)等同于3+(16-5),(16-5)的结果就是-5的补码,硬件里面存的-5就是1011,这样表示实现了减法到加法的转换。如果直接计算3-5的结果,则如(1)所示。
(1)
对3-5做个等价替换,改成补码的形式做加法运算,计算结果如式(2)所示。
(2)
(1)和(2)的结果是一样的,在硬件的世界里,它是不管正负的,于是大家约定位宽的最高bit就代表符号位,这样对于一个4bit的数据,如果你认为它是正数,就默认没有符号位,表示范围0~15,如果你认为它是负数,就要把最高位默认为符号位,表示范围-8~7,下表是4bit有符号数的存储数据。
二进制 | 十进制 | 二进制 | 十进制 |
0000 | 0 | 1111 | -1 |
0001 | 1 | 1110 | -2 |
0010 | 2 | 1101 | -3 |
0011 | 3 | 1100 | -4 |
0100 | 4 | 1011 | -5 |
0101 | 5 | 1010 | -6 |
0110 | 6 | 1001 | -7 |
0111 | 7 | 1000 | -8 |
上面这个表中0被归类到了正数部分,如果观察正负的绝对值,会觉得负数的绝对值总是比正数大了1,所以正常情况下补码的运算应该是除符号位以外,其他位减1再取反。什么?又要做减法,那不是白忙活一通?那就转换一下思路,能不能先取反再加1呢?答案是可以的,只要把补码设计成表格上的那个样子,就可以做到。
为什么一定要取反,因为硬件的基本单元是与非门,而取反用最简单的非门就可以实现,这就是负数为什么要用补码,而补码的算法又恰好是取反加1的原因,就是为了既让硬件容易做,又能方便的定义出0。
二、加减法
明白了负数的补码,再看加减法就会容易很多,只要位宽给够基本不会出问题。对于两个数相加,比如下面这种:
wire [7:0] a;
wire [3:0] b;
wire [8:0] c;
assign c = {1’b0,a}+{5’b0,b};
就两个重点,第一是c的位宽要比a和b的最大位宽再多1bit,a+b有可能大于a,多给1bit是很自然的,第二是加的时候要补齐位宽,虽然不补位宽计算也不会出错,但是有些编译器会报warning,排查起来很烦。
减法的运算也举个例子,下面这种:
wire [7:0] a;
wire [7:0] b;
wire [8:0] c;
assign c = {1’b0,a}-{1’b0,b};
重点还是c的位宽要多给1bit,这1bit是符号位。
上面两个都是正数的加减,如果是正负混合运算呢?这个时候就要介绍signed的使用方法了,signed的真实作用是决定如何对操作数扩位,如果是有符号数,强调一下signed,会自动补齐到左边的赋值位宽。举个例子:
wire [3:0] a;
wire [3:0] b;
wire [8:0] c;
assign c = $signed(a)-$signed(b);
此时signed会把4bit的a和b的最高位补齐到9bit。
三、乘法
乘法的计算其实会涉及到时序问题,但是如果位宽不太大,用组合逻辑也一样能做,比如a和b的位宽都是不超过8bit的正数,可以直接乘。
wire [7:0] a;
wire [3:0] b;
wire [11:0] c;
assign c = {4’b0,a}*{8’b0,b};
这里c的位宽就不是加1了,是a的位宽加上b的位宽,总之,左赋值的时候位宽要给够,还不能浪费。
如果是正负混合乘法呢?还是使用signed,举例如下:
wire [7:0] a;
wire [3:0] b;
wire [10:0] c;
assign c = $signed(a)*$signed(b);
注意这里,为什么c比前面定义的少了1bit,因为a和b都是有符号数,乘完之后留一个符号位就可以了,够用就好,多了浪费,作为一个ICer浪费是可耻的。
四、绝对值
就是计算(a-b)的绝对值,有两种写法。
1. 直接做
先做c=a-b,接下来判断c的符号位,符号位为1代表是负数,需要除符号位外按位取反再加1,符号位为0代表是正数,直接截掉最高位赋值。
wire [7:0] a;
wire [7:0] b;
wire [8:0] c;
wire [7:0] c_abs;
assign c = {1’b0,a}-{1’b0,b};
assign c_abs = c[8] ? (~c[7:0]+1’b1) : c[7:0];
这种写法的好处是省资源,只有一个减法器和一级选择逻辑,“+1”因为加的是常数,编译的时候会优化掉,所以那个不算加法器。坏处是timing会差一点儿,因为两级计算之间是级联的,要多用一点儿时间。
2. 比大小
a和b两个数比较大小,用大的减小的,保证得到的是正数,不用求绝对值。
wire [7:0] a;
wire [7:0] b;
wire [7:0] c;
assign c = (a > b) ? (a - b) : (b - a);
这种做法的好处是节省时间,timing会好,(a>b)、(a-b)和(b-a)这三个步骤是并行计算的,最后做一个选择,肯定比直接做要快一些,坏处是浪费资源,这里用了一个比较器,两个减法器和一级选择逻辑,资源大于第一个。
硬件没有非对即错,硬件是一种tradeoff(权衡、平衡),需要根据实际需求做取舍,有时候这是一门关于平衡的艺术。