关于 UART 你需要提前了解的东西
UART 按照一个个字节发送和接收数据,而协议中的一个数值可能占用一个字节,也可能分布在多个字节中。因此,在学习 UART 数据收发和协议解析之前,需要先理解进制、整数类型和位运算。
1. 关于进制
计算机中的数据最终都以二进制形式储存,也就是由 0 和 1 组成。我们平时最熟悉的是十进制,而在单片机开发中还经常使用十六进制。
那么对于数字 1010101,我们怎么知道这是一个二进制数字,还是一个十进制数字,还是一个十六进制数字呢?
在代码中,我们通常有如下约定:
- 二进制数字:在前面加入
0b,例如0b1010101。 - 十进制数字:通常不加入前缀,例如
1010101。 - 十六进制数字:在前面加入
0x,例如0x1010101。
十六进制采用“逢 16 进 1”。一位十六进制数需要表示 0~15,但我们熟悉的数字符号只有 0~9,因此用字母 A~F 表示 10~15:
| 二进制 | 十进制 | 十六进制 | 二进制 | 十进制 | 十六进制 |
|---|---|---|---|---|---|
| 0000 | 0 | 0 | 1000 | 8 | 8 |
| 0001 | 1 | 1 | 1001 | 9 | 9 |
| 0010 | 2 | 2 | 1010 | 10 | A |
| 0011 | 3 | 3 | 1011 | 11 | B |
| 0100 | 4 | 4 | 1100 | 12 | C |
| 0101 | 5 | 5 | 1101 | 13 | D |
| 0110 | 6 | 6 | 1110 | 14 | E |
| 0111 | 7 | 7 | 1111 | 15 | F |
代码中的十六进制字母也可以写成小写,例如 0xAF 和 0xaf 表示同一个数。
十六进制和二进制之间存在一种很方便的转换关系:一位十六进制数正好对应四位二进制数。这是因为四位二进制数共有 2⁴ = 16 种不同的组合,正好可以表示 0~15。
转换时,从二进制数的右侧开始,每四位分为一组,再分别转换成一位十六进制数。例如:
0b10110101
1011 0101
B 5
所以:0b10110101 = 0xB5 = 181
其中,低四位 0101 表示 5,对应十六进制的 5;高四位 1011 表示 11,对应十六进制的 B。
如果最高一组不足四位,可以在左侧补 0。例如:
0b101101 = 0b0010 1101 = 0x2D
反过来,把十六进制转换为二进制时,只需要把每一位十六进制数展开成四位二进制数。例如:
0x3A = 0b0011 1010
在本文讨论的单片机环境中,一个二进制位称为一个 bit(位),8 个 bit 组成一个 byte(字节)。我们常说一段数据有多少字节,就是在描述这段数据的大小,也就是它占用的存储空间。例如,2 字节的数据包含 16 个二进制位。
由于一位十六进制数对应 4 个 bit,所以一个 byte 可以用两位十六进制数表示。例如,二进制的 0b11111111 可以写成十六进制的 0xFF。在查看 UART 收发数据时,使用十六进制通常比直接查看二进制更简洁。
2. 数据类型
在讲数据类型之前,我们还是要明确:计算机存储数据的方式都是二进制位,任何数据类型最底层都表现为一串 0 和 1。
char
char 常用来表示字符,也可以用来存储较小的整数。它的大小是 1 byte。在本文讨论的单片机环境中,也就是 8 bits。
8 位二进制数字能表示 2⁸,也就是 256 种不同的位模式。按无符号整数解释时,对应的数值是 0~255。字符编码规定了数值和字符之间的对应关系,但这不意味着一个 char 能表示任意字符,例如 UTF-8 编码的汉字通常需要多个字节。普通 char 是否有符号取决于编译器和目标平台。
int
下面来讲讲 int。
在常见的 32 位单片机环境中,int 的大小是 4 bytes,也就是 32 bits。32 个二进制位共有 2³² = 4,294,967,296 种不同的位模式。作为对比,如果用 32 位无符号整数从 0 开始计数,每 1 ms 增加 1,大约 49.7 天后才会回绕到 0。
但是,有一个问题:全为 0 的二进制位表示 0,那么如何用 int 去表示负数呢?
在本文讨论的单片机环境中,有符号整数使用补码表示。对于 32 位的 int,最高位为 0 表示非负数,最高位为 1 表示负数。
那么,给你一个 int 的二进制形式,怎么把它转化为十进制呢?计算方式如下:
数值 = −最高位 × 2³¹ + 后面 31 位表示的无符号数
示例如下(省略号表示中间重复的位,总位数为 32):
0b100…000:−2³¹ + 0 = −2147483648。0b100…001:−2³¹ + 1 = −2147483647。0b111…111:−2³¹ + (2³¹ − 1) = −1。
最高位如果是 0,后面 31 位表示的二进制数字就等于实际的数值。因此,32 位有符号 int 的范围为 −2147483648~2147483647。
固定位宽的整数类型
在单片机中,用来存储整数的类型不止 int 一种,还有如下几种常用类型,可通过 #include <stdint.h> 使用:
uint8_t, uint16_t, uint32_t;
int8_t, int16_t, int32_t;
可以看到,有些类型最前面有 u,有些没有。我们拿 uint8_t 和 int8_t 进行对比。
uint8_t 前面的 u 代表 unsigned,中文意思是“无符号”。这里的符号就是正负号,无符号的意思就是这个数据类型只能表示非负数字,也就是最小值是 0;int 表示整数;而 8 代表这个数据类型的位宽是 8 位,也就是 1 byte。它能表示的数字范围是 0~255。
int8_t 前面没有 u,代表它能表示负数。数据类型的大小同样是 8 位,也就是 1 byte,但是它能表示的数字范围是 −128~127。
对比可以发现,int8_t 实际上就是把 256 种二进制形式切成了两半,一半表示负数,另一半表示非负数,代价就是能表示的正数上限变小了。
这里 int8_t 表示负数的机制和上面的 32 位 int 是一致的:最高位如果是 0,就是非负数;是 1,就是负数。转化为十进制的公式为:
数值 = −最高位 × 2⁷ + 后面 7 位表示的无符号数
uint16_t、int16_t、uint32_t、int32_t 也是同样的道理。
下面将无符号类型放在左侧,有符号类型放在右侧,对比它们的大小和数值范围:
| 无符号类型(uint) | 大小 | 数值范围 | 有符号类型(int) | 大小 | 数值范围 |
|---|---|---|---|---|---|
uint8_t |
8 位 / 1 字节 | 0~255 | int8_t |
8 位 / 1 字节 | −128~127 |
uint16_t |
16 位 / 2 字节 | 0~65535 | int16_t |
16 位 / 2 字节 | −32768~32767 |
uint32_t |
32 位 / 4 字节 | 0~4294967295 | int32_t |
32 位 / 4 字节 | −2147483648~2147483647 |
注意:int 的大小取决于平台,并非所有单片机上的 int 都是 32 位。在 int 为 32 位的平台上,它与 int32_t 的大小和数值范围一致;需要明确位宽时,优先使用上述固定位宽类型。
3. 位运算
这一部分运算是关于二进制位的操作。
a) 位移运算
左移
使用符号 <<。
对一个数字使用左移运算,会使这个数的二进制位整体向左移动,右侧空出的位置补 0。
例如:
0b10010010 (146)
左移 2 位
0b1001001000 (584)
在 C 代码中,移位运算前会先进行整数提升。在常见的 32 位单片机环境中,int8_t、uint8_t、int16_t、uint16_t 都会提升为 int,再进行移位。
对于需要整数提升的类型,如果 int 能表示原类型的所有值,就提升为 int;否则提升为 unsigned int。已经是 int、unsigned int 或位宽更大的整数类型,不会因为移位而自动变成更宽的类型。
例如,下面的计算在常见的 32 位单片机环境中按 32 位进行,结果不会在第 8 位截断:
uint8_t data = 0x92; // 0b10010010,即 146
int result = data << 2; // 0b1001001000,即 584
实际编程时,左移通常用于非负整数。负的有符号整数不能进行左移;非负的有符号整数左移后如果超出类型的表示范围,结果也是未定义的。无符号整数左移时,超出类型位宽的高位会被舍弃。需要按 32 位无符号数进行移位时,可以先转换为 uint32_t,例如 (uint32_t)data << 24。
右移
使用符号 >>。
对一个数字使用右移运算,会使这个数的二进制位整体向右移动,移出的低位被舍弃,左侧空出的位置按规则补 0 或 1。
右移有两种方式:
- 逻辑右移:左侧空出的位置一律补 0。
- 算术右移:保留符号,正数和 0 补 0,负数补 1。
在常见单片机编译器中,可以这样理解 >>:无符号数右移补 0;有符号数采用算术右移,正数和 0 补 0,负数补 1。 有符号正数的算术右移与逻辑右移结果相同。
需要注意,在 C17 及更早的 C 语言标准中,有符号负数右移的具体行为由编译器决定。常见单片机编译器一般采用高位补 1 的算术右移,实际项目中应以所用编译器的说明为准。
只有有符号负数在算术右移时才补 1,不能仅凭原数据的最高位是 1,就把它当作负数。 例如,同样的 8 位模式 0b10000000,在 uint8_t 中表示 128,在 int8_t 中表示 −128:
uint8_t a = 0x80; // 0b10000000,表示 128
int8_t b = -128; // 8 位补码为 0b10000000,表示 -128
int8_t c = 64; // 0b01000000,表示 64
// 以下只展示移位前后的低 8 位,负数按补码解读
int x = a >> 2; // 0b10000000 → 0b00100000,补 0,结果为 32
int y = b >> 2; // 0b10000000 → 0b11100000,补 1,结果为 -32
int z = c >> 2; // 0b01000000 → 0b00010000,补 0,结果为 16
逻辑右移在嵌入式中也很常用,例如将无符号数据的高几位移动到低位,便于解包 UART 数据。
b) 位与运算
比如两个二进制数字:a = 0b01001101,b = 0b01100110。通过位与运算 &,产生一个新的数字 c = 0b01000100。
只有 a 和 b 相同位置都是 1,新的数字的该位置才是 1,否则是 0。
| 数字 | 第 7 位 | 第 6 位 | 第 5 位 | 第 4 位 | 第 3 位 | 第 2 位 | 第 1 位 | 第 0 位 |
|---|---|---|---|---|---|---|---|---|
a |
0 | 1 | 0 | 0 | 1 | 1 | 0 | 1 |
b |
0 | 1 | 1 | 0 | 0 | 1 | 1 | 0 |
c = a & b |
0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 |
红色标出了第 6 位和第 2 位:这两个位置的 a 和 b 都是 1,所以结果也为 1。
c) 位或运算
还是两个数字:a = 0b01001101,b = 0b01100110。通过位或运算 |,产生一个新的数字 c = 0b01101111。
只要 a 和 b 相同位置至少有一个是 1,新的数字的该位置就是 1,否则是 0。
| 数字 | 第 7 位 | 第 6 位 | 第 5 位 | 第 4 位 | 第 3 位 | 第 2 位 | 第 1 位 | 第 0 位 |
|---|---|---|---|---|---|---|---|---|
a |
0 | 1 | 0 | 0 | 1 | 1 | 0 | 1 |
b |
0 | 1 | 1 | 0 | 0 | 1 | 1 | 0 |
c = a | b |
0 | 1 | 1 | 0 | 1 | 1 | 1 | 1 |
红色标出了 a 或 b 中为 1 的位,以及这些位经过位或运算后得到的结果。