深入浅出C语言位操作,全面!精华!!

## 数据在内存中的存储方式原理

在计算机中,所有的数据都是以二进制的形式存储在内存中的。二进制就是只有0和1两种状态的数制,每一个0或1称为一个比特(bit),也就是二进制位。一个比特是计算机中最小的信息单位,但是一个比特表示的信息太少,所以通常我们用8个比特组成一个字节(byte),一个字节可以表示256种不同的状态(2^8=256)。一个字节是计算机中最基本的数据单位,也是内存寻址的最小单位。

在C语言中,不同类型的数据占用不同字节数的内存空间。例如,char类型占用1个字节,int类型占用2或4个字节(取决于编译器和操作系统),long类型占用4或8个字节(取决于编译器和操作系统),float类型占用4个字节,double类型占用8个字节等。我们可以用sizeof运算符来获取一个数据类型或变量所占用的字节数。

例如:

#include <stdio.h>
int main()
{
  printf("sizeof(char) = %d\n", sizeof(char)); //输出1
  printf("sizeof(int) = %d\n", sizeof(int)); //输出2或4
  printf("sizeof(long) = %d\n", sizeof(long)); //输出4或8
  printf("sizeof(float) = %d\n", sizeof(float)); //输出4
  printf("sizeof(double) = %d\n", sizeof(double)); //输出8
  return 0;
}

当我们定义一个变量时,编译器会为这个变量分配一块连续的内存空间,并给这个变量一个地址。我们可以用&运算符来获取一个变量的地址。例如:

#include <stdio.h>
int main()
{
  int a = 10; //定义一个int类型的变量a,并赋值为10
  printf("a = %d\n", a); //输出a的值
  printf("&a = %p\n", &a); //输出a的地址,%p表示以十六进制形式输出指针
  return 0;
}

可能会输出:

a = 10
&a = 0x7ffeedb9f9ac

这里我们可以看到,变量a的值为10,而它的地址为0x7ffeedb9f9ac(这个地址可能会因为编译器和操作系统而不同)。这个地址表示了变量a在内存中的起始位置,由于int类型占用4个字节(32位),所以变量a实际上占用了从0x7ffeedb9f9ac到0x7ffeedb9f9af这4个字节的内存空间。我们可以把这4个字节分别写成二进制形式:

0x7ffeedb9f9ac: 0000 0000
0x7ffeedb9f9ad: 0000 0000
0x7ffeedb9f9ae: 0000 1010
0x7ffeedb9f9af: 0000 0000

这里我们可以看到,变量a的值10在内存中是以二进制形式存储的,即0000 0000 0000 0000 0000 1010 0000 0000。注意这里是按照从低地址到高地址的顺序存储的,这种存储方式称为**小端序**(little-endian)。也就是说,变量a的最低有效位(LSB)存储在最低的地址上,而变量a的最高有效位(MSB)存储在最高的地址上。另一种存储方式是**大端序**(big-endian),即变量a的MSB存储在最低的地址上,而变量a的LSB存储在最高的地址上。不同的编译器和操作系统可能采用不同的存储方式,所以在进行跨平台的数据传输时,需要注意字节序的转换问题。

对于有符号类型的数据,如int、char、long等,它们在内存中的存储方式还涉及到**符号位**(sign bit)的问题。符号位是用来表示数据是正数还是负数的,通常约定最高位为符号位,0表示正数,1表示负数。例如,一个有符号的char类型占用1个字节(8位),它可以表示-128到127之间的整数。如果我们定义一个有符号的char类型的变量b,并赋值为-10,那么它在内存中的存储形式为:

0x7ffeedb9f9b0: 1111 0110

这里我们可以看到,变量b的符号位为1,表示它是一个负数。那么剩下的7位是如何表示-10这个数值的呢?这就涉及到三种不同的编码方式:**原码**(true form)、**反码**(one's complement)和**补码**(two's complement)。

原码是最直观的一种编码方式,它就是将一个数值转换成二进制形式,并在最高位加上符号位。例如,10的原码为0000 1010,-10的原码为1000 1010。原码很容易理解和计算,但是它有两个缺点:一是存在正零和负零两种表示方式,即0000 0000和1000 0000都表示零,这会造成混淆和浪费;二是不能直接进行加减运算,因为符号位会影响结果。例如,如果我们想计算-10+5=-5,用原码表示就是:

1000 1010
+ 0000 0101
-----------
1000 1111

这个结果显然是错误的,因为1000 1111表示-15而不是-5。

反码是对原码进行改进的一种编码方式,它规定正数的反码与原码相同,而负数的反码是对原码除了符号位之外的所有位取反。例如,10的反码仍然为0000 1010,而-10的反码为1111 0101。反码解决了正零和负零两种表示方式的问题,因为它只有一个零,即0000 0000。但是反码仍然不能直接进行加减运算,因为结果可能会出现溢出或者需要进行校正。例如,如果我们想计算-10+5=-5,用反码表示就是:

1111 0101
+ 0000 0101
-----------
1111 1010

这个结果也是错误的,因为1111 1010表示-6而不是-5。我们需要对结果进行校正,即如果结果的符号位为1,则需要将结果加上1才能得到正确答案。所以正确结果应该是1111 1011。

补码是目前计算机中最常用的一种编码方式,它规定正数的补码与原码相同,而负数的补码是对原码除了符号位之外的所有位取反,然后再加上1。例如,10的补码仍然为0000 1010,而-10的补码为1111 0110。补码不仅解决了正零和负零两种表示方式的问题,而且可以直接进行加减运算,而不需要进行校正或者溢出处理。例如,如果我们想计算-10+5=-5,用补码表示就是:

1111 0110
+ 0000 0101
-----------
1111 1011

这个结果就是正确的,因为1111 1011表示-5。我们可以看到,用补码进行加减运算时,只需要将两个数的所有位相加,然后忽略最高位的进位(如果有的话),就可以得到正确答案。这样就大大简化了计算机中的算术运算。

由于补码的优越性,目前计算机中存储有符号类型的数据都是采用补码的方式。所以我们在编程时需要注意区分有符号类型和无符号类型的数据,以及它们在内存中的存储方式和取值范围。例如,一个无符号的char类型占用1个字节(8位),它可以表示0到255之间的整数。如果我们定义一个无符号的char类型的变量c,并赋值为255,那么它在内存中的存储形式为:

0x7ffeedb9f9b1: 1111 1111

这里我们可以看到,变量c没有符号位,它的所有位都是1,表示255。如果我们将变量c强制转换成有符号的char类型,那么它在内存中的存储形式不变,但是它的解释方式变了:

0x7ffeedb9f9b1: 1111 1111

这里我们可以看到,变量c现在有一个符号位,为1,表示它是一个负数。那么它的数值是多少呢?我们需要用补码的方式来计算。首先,我们将除了符号位之外的所有位取反:

0000 0000

然后,我们再加上1:

0000 0001

这就是变量c的原码,表示-1。所以当我们将一个无符号类型的数据强制转换成有符号类型时,可能会出现数据溢出或者意义改变的情况。这是我们在编程时需要注意避免或者处理好的问题。


## 位操作的方法


位操作(bitwise operation)是指对数据在二进制位层面上进行操作的一种方法。C语言提供了6种位操作符(bitwise operator),分别是:按位与(&)、按位或(|)、按位异或(^)、按位取反(~)、左移(<<)和右移(>>)。这些操作符可以对整型数据(包括char、int、long等)进行操作,得到一个新的整型数据作为结果。下面我们分别介绍这些操作符的含义和用法。


### 按位与(&)


按位与操作符(&)是对两个整型数据进行逻辑与运算(AND operation)的操作符。它会将两个数据对应的每一位进行逻辑与运算,并将结果作为新数据的对应位。逻辑与运算遵循以下规则:

0 & 0 = 0
0 & 1 = 0
1 & 0 = 0
1 & 1 = 1

也就是说,只有当两个数对应的每一位都为1才能得到1,否则得到0。例如,如果我们有两个整型数据a和b,它们的二进制表示为:

a: 0000 1100
b: 0010 1010

那么a&b的结果为:

a & b: 0000 1000

按位与操作符有以下几种常见的用途:

- **清零**:如果我们想将一个整型数据的某些位清零,即将它们变成0,我们可以用按位与操作符和一个掩码(mask)进行操作。掩码是一个特殊的整型数据,它的某些位为1,而其他位为0。我们可以根据需要设计掩码的值,使得它的1位对应我们想保留的位,而它的0位对应我们想清零的位。例如,如果我们想将一个整型数据的低4位清零,我们可以用它和1111 0000进行按位与操作。例如,如果我们有一个整型数据c,它的二进制表示为:

c: 0101 1011

那么c&11110000的结果为:

c & 11110000: 0101 0000

这样就将c的低4位清零了。

- **取值**:如果我们想获取一个整型数据的某些位的值,即将它们提取出来,我们也可以用按位与操作符和一个掩码进行操作。掩码的设计原则同上,只是这次我们要使得它的1位对应我们想提取的位,而它的0位对应我们不关心的位。例如,如果我们想获取一个整型数据的第3位和第4位的值(从右往左数),我们可以用它和0000 1100进行按位与操作。例如,如果我们有一个整型数据d,它的二进制表示为:

d: 0110 0111

那么d&00001100的结果为:

d & 00001100: 0000 0100

这样就将d的第3位和第4位提取出来了。

### 按位或(|)

按位或操作符(|)是对两个整型数据进行逻辑或运算(OR operation)的操作符。它会将两个数据对应的每一位进行逻辑或运算,并将结果作为新数据的对应位。逻辑或运算遵循以下规则:

0 | 0 = 0
0 | 1 = 1
1 | 0 = 1
1 | 1 = 1

也就是说,只有当两个数对应的每一位都为0时才能得到0,否则得到1。例如,如果我们有两个整型数据e和f,它们的二进制表示为:

e: 0000 1100
f: 0010 1010

那么e|f的结果为:

e | f: 0010 1110

按位或操作符有以下几种常见的用途:

- **置位**:如果我们想将一个整型数据的某些位置位,即将它们变成1,我们可以用按位或操作符和一个掩码进行操作。掩码的设计原则同上,只是这次我们要使得它的1位对应我们想置位的位,而它的0位对应我们不关心的位。例如,如果我们想将一个整型数据的低4位置位,我们可以用它和0000 1111进行按位或操作。例如,如果我们有一个整型数据g,它的二进制表示为:

g: 0101 0000

那么g|00001111的结果为:

g | 00001111: 0101 1111

这样就将g的低4位置位了。

- **合并**:如果我们想将两个整型数据的某些位合并成一个新的数据,我们也可以用按位或操作符和一些掩码进行操作。例如,如果我们想将一个整型数据的高4位和另一个整型数据的低4位合并成一个新的数据,我们可以先用两个掩码分别提取出两个数据的高4位和低4位,然后再用按位或操作符将它们合并。例如,如果我们有两个整型数据h和i,它们的二进制表示为:

h: 0101 1011
i: 0010 0101

那么我们可以先用11110000和00001111分别提取出h和i的高4位和低4位:

h & 11110000: 0101 0000
i & 00001111: 0000 0101

然后再用按位或操作符将它们合并:

(h & 11110000) | (i & 00001111): 0101 0101

这样就得到了一个新的数据,它的高4位来自h,而低4位来自i。

### 按位异或(^)

按位异或操作符(^)是对两个整型数据进行逻辑异或运算(XOR operation)的操作符。它会将两个数据对应的每一位进行逻辑异或运算,并将结果作为新数据的对应位。逻辑异或运算遵循以下规则:

0 ^ 0 = 0
0 ^ 1 = 1
1 ^ 0 = 1
1 ^ 1 = 0

也就是说,只有当两个数对应的每一位不同时才能得到1,否则得到0。例如,如果我们有两个整型数据j和k,它们的二进制表示为:

j: 0000 1100
k: 0010 1010

那么j^k的结果为:

j ^ k: 0010 0110

按位异或操作符有以下几种常见的用途:

- **交换**:如果我们想交换两个整型数据的值,我们可以用按位异或操作符来实现,而不需要使用额外的变量。这是因为按位异或操作符具有以下性质:

a ^ a = 0
a ^ 0 = a
a ^ b = b ^ a
(a ^ b) ^ c = a ^ (b ^ c)

也就是说,任何数和自身异或都得到0,任何数和0异或都得到自身,两个数的异或结果与顺序无关,异或运算满足结合律。利用这些性质,我们可以用以下方法来交换两个整型数据的值:

a = a ^ b;
b = a ^ b;
a = a ^ b;

例如,如果我们有两个整型数据l和m,它们的值分别为10和20,那么我们可以用按位异或操作符来交换它们的值:

l: 0000 1010
m: 0001 0100
l = l ^ m; // l: 0001 1110
m = l ^ m; // m: 0000 1010
l = l ^ m; // l: 0001 0100

这样就实现了l和m的值的交换,而不需要使用额外的变量。

- **加密**:如果我们想对一个整型数据进行简单的加密或者解密,我们也可以用按位异或操作符来实现,只需要使用一个密钥(key)。密钥是一个特殊的整型数据,它的长度和要加密或者解密的数据相同。我们可以根据需要设计密钥的值,使得它具有一定的随机性和复杂性。例如,如果我们想对一个整型数据n进行加密或者解密,我们可以用它和一个密钥p进行按位异或操作。例如,如果我们有一个整型数据n,它的值为30,以及一个密钥p,它的值为15,那么我们可以用按位异或操作符来加密或者解密n:

n: 0001 1110
p: 0000 1111
n = n ^ p; // n: 0001 0001 (加密后)
n = n ^ p; // n: 0001 1110 (解密后)

这样就实现了n的加密和解密,而且只需要使用一个密钥。这种方法的优点是简单易行,而且加密和解密过程相同。但是它也有缺点,就是如果密钥被泄露或者被猜测出来,那么加密就失去了意义。所以这种方法只适合用于一些不太重要或者不太敏感的数据的加密和解密。

## 如何用宏实现位操作

宏(macro)是一种在C语言中定义常量或者函数的一种方法。宏可以用#define指令来定义,它有以下形式:

#define 标识符 替换文本

其中标识符是宏的名称,替换文本是宏展开后要替换的内容。替换文本可以是一个常量、一个表达式、一个语句甚至是一段代码。当编译器在源代码中遇到标识符时,就会用替换文本来替换它。这样就可以实现一些简单的常量定义或者函数定义。例如:

#include <stdio.h>
#define PI 3.14 //定义一个常量PI
#define SQUARE(x) ((x) * (x)) //定义一个计算平方的函数
int main()
{
  printf("PI = %f\n", PI); //输出PI的值
  printf("SQUARE(5) = %d\n", SQUARE(5)); //输出5的平方
  return 0;
}

可能会输出:

PI = 3.140000
SQUARE(5) = 25

这里我们可以看到,我们用#define指令定义了一个常量PI和一个函数SQUARE,然后在代码中使用它们。编译器会在预处理阶段将它们替换成对应的值或者表达式。这样就可以实现一些简单的常量或者函数的定义,而不需要使用变量或者函数。

我们也可以用宏来实现一些位操作的函数,这样可以提高代码的可读性和复用性。例如,我们可以用宏来定义一些常用的位操作函数,如:

//定义一个获取某个整型数据某一位的值的函数
#define GET_BIT(n, i) (((n) >> (i)) & 1)
//定义一个将某个整型数据某一位清零的函数
#define CLEAR_BIT(n, i) ((n) & ~(1 << (i)))
//定义一个将某个整型数据某一位置位的函数
#define SET_BIT(n, i) ((n) | (1 << (i)))
//定义一个将某个整型数据某一位取反的函数
#define TOGGLE_BIT(n, i) ((n) ^ (1 << (i)))

这里我们可以看到,我们用按位移动、按位与、按位或和按位异或操作符来实现了四个位操作函数。其中,n表示要操作的整型数据,i表示要操作的位的位置(从右往左数)。我们可以用这些宏来方便地进行一些位操作。例如:

#include <stdio.h>
//定义宏
#define GET_BIT(n, i) (((n) >> (i)) & 1)
#define CLEAR_BIT(n, i) ((n) & ~(1 << (i)))
#define SET_BIT(n, i) ((n) | (1 << (i)))
#define TOGGLE_BIT(n, i) ((n) ^ (1 << (i)))
int main()
{
  int n = 30; //定义一个整型数据n,并赋值为30
  printf("n = %d\n", n); //输出n的值
  printf("GET_BIT(n, 3) = %d\n", GET_BIT(n, 3)); //输出n的第3位的值
  printf("CLEAR_BIT(n, 3) = %d\n", CLEAR_BIT(n, 3)); //输出将n的第3位清零后的值
  printf("SET_BIT(n, 3) = %d\n", SET_BIT(n, 3)); //输出将n的第3位置位后的值
  printf("TOGGLE_BIT(n, 3) = %d\n", TOGGLE_BIT(n, 3)); //输出将n的第3位取反后的值
  return 0;
}

可能会输出:

n = 30
GET_BIT(n, 3) = 1
CLEAR_BIT(n, 3) = 22
SET_BIT(n, 3) = 30
TOGGLE_BIT(n, 3) = 22

这里我们可以看到,我们用宏来实现了一些位操作,并在代码中使用它们。这样就提高了代码的可读性和复用性。

## 位操作要注意什么

虽然位操作有很多好处,但是也有一些需要注意的地方。下面我们列举一些常见的注意事项:

- **数据类型**:在进行位操作时,需要注意数据类型的长度和符号。不同类型的数据占用不同字节数的内存空间,所以在进行移位或者掩码操作时,需要注意不要超出数据类型的范围。例如,如果我们有一个char类型的数据q,它占用1个字节(8位),那么如果我们对它进行左移8位或者右移8位,就会导致结果为0。例如:

#include <stdio.h>
int main()
{
  char q = 10; //定义一个char类型的数据
  printf("q = %d\n", q); //输出q的值
  printf("q << 8 = %d\n", q << 8); //输出q左移8位的值
  printf("q >> 8 = %d\n", q >> 8); //输出q右移8位的值
  return 0;
}

可能会输出:

q = 10
q << 8 = 0
q >> 8 = 0

这里我们可以看到,由于char类型只有8位,所以对它进行左移或者右移8位,就会导致所有位都变成0。所以在进行移位操作时,需要注意数据类型的长度,以免造成数据丢失。

另外,对于有符号类型的数据,还需要注意符号位的问题。在进行右移操作时,有符号类型的数据会进行**算术右移**(arithmetic right shift),即保持符号位不变,然后将其他位向右移动,并用符号位填充空出来的位。这样可以保持数据的符号不变。例如,如果我们有一个有符号的char类型的数据r,它的值为-10,那么它在内存中的存储形式为:

r: 1111 0110

如果我们对它进行右移2位,那么结果为:

r >> 2: 1111 1101

这里我们可以看到,由于r是一个负数,所以它的符号位为1,并且保持不变。然后它的其他位向右移动了2位,并用1填充了空出来的位。这样就保持了r的符号不变,并且得到了正确的结果,即-3。

但是,在进行左移操作时,有符号类型的数据会进行**逻辑左移**(logical left shift),即将所有位向左移动,并用0填充空出来的位。这样可能会导致数据的符号改变。例如,如果我们对r进行左移2位,那么结果为:

r << 2: 1101 1000

这里我们可以看到,由于r是一个负数,所以它的符号位为1。但是在左移操作后,它的符号位变成了0,并且被0填充了空出来的位。这样就改变了r的符号,并且得到了错误的结果,即56。

所以,在进行左移操作时,需要注意数据类型的符号,以免造成数据溢出或者意义改变。

- **优先级**:在进行位操作时,还需要注意操作符的优先级和结合性。不同的操作符有不同的优先级和结合性,优先级高的操作符会先执行,优先级相同的操作符会按照结合性规则执行。C语言中各种操作符的优先级和结合性可以参考以下表格:

| 操作符 | 描述 | 结合性 |

| :---: | :---: | :---: |

| () [] -> . | 函数调用、数组下标、指针成员访问、对象成员访问 | 左到右 |

| ! ~ ++ -- + - * & (type) sizeof | 逻辑非、按位取反、自增、自减、正负号、解引用、取地址、强制类型转换、求字节数 | 右到左 |

| * / % | 乘法、除法、取余 | 左到右 |

| + - | 加法、减法 | 左到右 |

| << >> | 左移、右移 | 左到右 |

| < <= > >= | 小于、小于等于、大于、大于等于 | 左到右 |

| == != | 等于、不等于 | 左到右 |

| & | 按位与 | 左到右 |

| ^ | 按位异或 | 左到右 |

| \| | 按位或 | 左到右 |

| && | 逻辑与 | 左到右 |

| \|\| | 逻辑或 | 左到右 |

| ?: | 条件运算符 | 右到左 |

| = += -= *= /= %= <<= >>= &= ^= \|= | 赋值运算符 | 右到左 |

| , | 逗号运算符 | 左到右 |

这里我们可以看到,位操作符的优先级并不是很高,比算术运算符、关系运算符和逻辑运算符都要低。所以在进行位操作时,需要注意使用括号来明确运算的顺序,以免造成错误或者歧义。例如,如果我们想计算一个整型数据s的第3位和第4位的值(从右往左数),我们可以用它和0000 1100进行按位与操作。但是如果我们直接写成s&00001100,那么可能会被编译器解释成s&(00001100),即先计算00001100的值,然后再和s进行按位与操作。这样就会导致错误的结果,因为00001100是一个八进制数,它的值为12,而不是我们想要的掩码。所以我们应该写成s&0x0c,或者用括号明确表示十六进制数,即s&(0x0c)。这样就可以得到正确的结果。

- **移位方向**:在进行移位操作时,还需要注意移位的方向和含义。左移操作(<<)是将一个整型数据的所有位向左移动一定的位数,并用0填充空出来的位。这相当于将这个数据乘以2的移位数次方。例如,如果我们有一个整型数据t,它的值为10,那么它在内存中的存储形式为:

t: 0000 1010

如果我们对它进行左移2位,那么结果为:

t << 2: 0010 1000

这相当于将t乘以2^2,即40。

右移操作(>>)是将一个整型数据的所有位向右移动一定的位数,并用符号位或者0填充空出来的位。对于有符号类型的数据,右移操作会进行算术右移,即保持符号位不变,并用符号位填充空出来的位。这相当于将这个数据除以2的移位数次方,并向下取整。例如,如果我们有一个有符号的char类型的数据u,它的值为-10,那么它在内存中的存储形式为:

u: 1111 0110

如果我们对它进行右移2位,那么结果为:

u >> 2: 1111 1101

这相当于将u除以2^2,并向下取整,即-3。

对于无符号类型的数据,右移操作会进行逻辑右移,即不考虑符号位,并用0填充空出来的位。这也相当于将这个数据除以2的移位数次方,并向下取整。例如,如果我们有一个无符号的char类型的数据v,它的值为240,那么它在内存中的存储形式为:

v: 1111 0000

如果我们对它进行右移2位,那么结果为:

v >> 2: 0011 1100

这相当于将v除以2^2,并向下取整,即60。

所以,在进行移位操作时,需要注意数据类型的符号和长度,以及移位方向和含义。


## 总结

本文深入分析了C语言位操作的原理和用法,从数据在内存中的存储方式开始,介绍了原码、反码和补码的概念和区别,然后介绍了按位与、按位或、按位异或、按位取反、左移和右移这六种位操作符的含义和用法,以及如何用宏来实现一些常用的位操作函数。最后,我们列举了一些进行位操作时需要注意的事项,如数据类型、优先级和移位方向等。

我们可以看到,位操作是一种非常强大和灵活的方法,它可以让我们直接操作数据在二进制层面上的状态,从而实现一些高效和简洁的功能。例如,我们可以用位操作来进行数据的清零、置位、取值、合并、交换、加密等。但是,位操作也有一些需要注意的地方,如数据类型的长度和符号、操作符的优先级和结合性、移位方向和含义等。所以,在进行位操作时,我们需要有一定的基础知识和经验,以免造成错误或者歧义。


系列文章持续更新,如果觉得有帮助请点赞+关注!

举报
评论 0