Atom's Blog

这个博客已通过认证

第一篇:类型、数据表示与运算

📅 | 🏷️
文章目录

1. 数据类型和存储方式

C 语言的基本类型(Primitive Type) 简单的可分为两类:整型和浮点型。整型包含:char, short, int, long 以及它们的无符号形式;浮点型包含:float 和 double。C 语言面向底层编程,需要极高的内存等资源的利用率,以及需要注意溢出,截断等问题,特别是针对嵌入式硬件。因此,不同于其他语言,程序员对类型所占用存储的大小,以及类型所能表示的范围,需要了然于心。

1.1 整型

按常见实现补齐:1 字节 = 8 位,有符号整数用补码,char 有无符合看平台实现,long 在 64 位 Windows 为 4 字节、在 Linux/macOS 常见为 8 字节。

类型所占字节范围示例二进制表示(示例)
char1-2^7 ~ 2^7 - 1-1 或 'A'-1:1111 1111;'A':0100 0001
unsigned char10 ~ 2^8 - 12551111 1111
short2-2^15 ~ 2^15 - 1-11111 1111 1111 1111
unsigned short20 ~ 2^16 - 1655351111 1111 1111 1111
int4-2^31 ~ 2^31 - 1-11111 1111 1111 1111 1111 1111 1111 1111
unsigned int40 ~ 2^32 - 14294967295U1111 1111 1111 1111 1111 1111 1111 1111
long4/84B:-2^31 ~ 2^31 - 1;8B:-2^63 ~ 2^63 - 1-1L4B:32 个 1;8B:64 个 1
unsigned long4/84B:0 ~ 2^32 - 1;8B:0 ~ 2^64 - 1ULONG_MAX4B:32 个 1;8B:64 个 1
long long8-2^63 ~ 2^63 - 1-1LL64 个 1
unsigned long long80 ~ 2^64 - 1ULLONG_MAX64 个 1
说明:

32 位无符号整数范围大概是 42.9 亿,64 位的无符号整数范围大概是 1844 京

1.2 有符号整数的补码表示

现代计算机中的固定宽度有符号二进制整数,通常使用二进制补码表示。正数的补码是原码,负数的补码反码+1。以 5 和 -5 为例。

另外一种更方便的计算方法就是忘记原码、反码。补码实际上就是在一个模 W 的世界里,循环打转。示意图如下:

补码在模 2^W 中的循环示意

1.3 浮点数

浮点数采用 IEEE 标准法表示 V=(−1)sM2EV = (-1)^sM2^E,分为:符号位(s), 指数位(exp),尾数位(frac)。

float(32 位)与 double(64 位)的位域划分

将十进制转为浮点数

以 5.5 为例:

2. 字面量

C 语言的字面量分为:整数、浮点、字符、字符串、符合字面量。需要注意的是进制表示、转义字符、以及字符串字面量末尾自动的 \0。

类别示例重点
整数字面量10, 077, 0x10, 0b1010进制、类型推导、后缀
浮点字面量3.14, 3.14f, 1e10默认 double
字符字面量'A', '\n', '\x41'在 C 中类型通常是 int
字符串字面量"hello"本质是字符数组,末尾自动 \0
转义字符'\n', '\0', '\xFF'八进制/十六进制转义尤其容易坑
复合字面量(struct Point){1, 2}C99,属于进阶但非常实用

从 C23 开始,对整数来说, C 支持数字分隔符 '

long a = 10'000L;

2.1 负数数字面量

对于负数 -10,来说,它实际上是 -(10),其中 10 是数字字面量,而 - 是运算符。这主要是由于在 C 语言中没有定义负数的字面量,-10 在词法中被当作两个 token。如果我们查看 INT_MIN 这个宏会发现其定义可能如下:

1#define INT_MIN (-INT_MAX - 1)

而非:

1#define INT_MIN -2147483648

这是因为 2147483648 已经超出了 int 所能表示的范围,它可能是 long 或 long long 类型了。windows11, 64bit 输出如下:

1printf("%zu\n", sizeof(INT_MIN)); // 4 -> int
2printf("%zu\n", sizeof(-2147483648)); // 8 -> long long

另外的对于 INT_MIN 我们肯定希望它的 sizeof 就是 int 的大小,毕竟它是最小的 int。而通过 -INT_MAX - 1 这样的运算,我们知道其是两个 int 类型的运算,其结果就是 int,而结果 -2147483648 恰好就在 int 所能表示的范围内。

为什么语言不把负号设计成字面量的一部分?

因为那样会带来很多麻烦:

  1. 词法分析会变复杂 如果 -1 是一个字面量,那 a-1 中的 -1 怎么算?
    a - 1 到底是 a 减去字面量 1,还是 a 和字面量 -1 相邻?
    词法分析通常不依赖上下文,所以把符号留给运算符更简单。
  2. 空格会变得很奇怪 - 1 和 -1 如果含义不同,会非常反直觉。
  3. 与表达式语法一致 +1 也不是正字面量,+ 是一元加号。
    -1 同理,- 是一元负号。
    这样整个表达式体系更统一。

2.2 整数进制字面量

110      // 十进制 10
2
3012     // 八进制 12 = 十进制 10
4
50x10    // 十六进制 = 16
6
70XFF    // 十六进制 = 255
8
90b1010'1111 // 二进制

注意:八进制开头为0

3.2 整数字面量后缀

常见表示如下:

1100U
2100L
3100UL
4100LL
5100ULL

其含义:

U/L/UL/…/ULL 也可以采用小写,但是可读性差,一般推荐大写

需要特别注意的是,C99/C11 规定:无后缀的十进制整数常量,类型依次从 int、long int、long long int 中选第一个能表示它的类型。所以 long long x = 10000000000中的 10000000000 不是 long long 类型,赋值给 x 发生了隐式类型转换。在工程里一般常写 long long x = 10000000000L,特别是对于宏定义:#define TIMEOUT 10000000000LL。在嵌入式里面,我们常写 #define BIT31 (1U << 31) 而非 #define BIT31 (1 << 31)

3.3 浮点字面量

浮点的字面量默认是 double,其字面量常见写法如下:

写法类型
3.14double
3.14ffloat
3.14Ffloat
3.14Llong double

3.4 字符字面量

请注意,字符字面量默认是 int 类型。看下面的代码和输出。

1printf("%zu\n", sizeof('A')); // 4
2char c = 'A';
3printf("%zu\n", sizeof(c)); // 1

字符字面量实际上对应的是 ASCII 码的整数值。对于 if (c >= '0' && c <= '9') 比较的实际上是编码值。

3.5 转义字符

下面,主要列出一些冷门的转义字符,其他的就不复缀了。

转义字符含义
‘\0’字符串结尾
‘\101’八进制, 8^2 + 1 = 65
‘\x41’十六进制

3.6 字符串字面量

字符串字面量默认结尾带了 \0,当一个 char * 指针指向字符串字面量时,我们不能通过该指针去修改字符串字面量(这是因为字符串字面量通常放在只读区)。以下行为,对于编译器来说属于未定义行为。

1char *p = "hello";
2p[0] = 'H'; // 未定义行为

对于字符串字面量,存在一个妙用语法,那就是相邻字符串字面量会自动拼接。

1const char *s =
2    "hello "
3    "world";

在编译阶段会对其进行拼接,因此,可以得到如下等效字面量:

1"hello world"

所以写很长字符串时经常:

1printf(
2    "Name: %s\n"
3    "Age: %d\n"
4    "Address: %s\n",
5    name,
6    age,
7    address
8);

3.7 不是字面量的宏定义

NULL 是宏定义,其实现可能如下:

1#define NULL ((void *)0)

同样的, true 和 false 也不是字面量,它们来自 stdbool.h。

3.8 复合字面量

整数、浮点和字符字面量主要直接表示一个值,本身不创建具有独立身份和存储位置的对象;字符串字面量是一个特殊情况,它对应一个具有静态存储期的字符数组对象。复合字面量(Compound Literal) 则显式创建一个匿名对象,因此具有实际存储位置,并具有相应的存储期和生命周期。常见的写法有:

 1struct Config {
 2    int port;
 3    int timeout;
 4    int retry;
 5};
 6void print_array(const int *arr, int n)
 7{
 8    for (int i = 0; i < n; i++) {
 9        printf("%d\n", arr[i]);
10    }
11}
12
13print_array((int[]){10, 20, 30}, 3);
14start_server((struct Config){
15    .port = 8080,
16    .timeout = 30,
17    .retry = 3
18});

注意:不要将强制类型转换与符合字面量搞混。

  • 强转:(type) expression
  • 符合字面量:(type){ initializer }

3. 整数提升

下面程序输出的字节大小很反直觉。

 1#include <stdint.h>
 2#include <stdio.h>
 3
 4int main(void)
 5{
 6    uint8_t a = 200;
 7    uint8_t b = 100;
 8
 9    printf("%zu\n", sizeof(a));
10    printf("%zu\n", sizeof(a + b));
11}

其中:sizeof(a) 为 1,sizeof(a + b) 却是 4。这里面就有整数提升的猫腻。C 语言偷偷把算术运算的双方转成了 int。那么 C 语言为啥要做内存提升呢?这是由于早期 C 语言诞生于 PDP-11 这类机器,这类机器按照自然字长来运算,比如说 16 bits 或 32 bits。再则就是现代寄存器大多数是 32 bits 或 64 bits 的,使用 int 来运算最快,最自然,省去了额外的掩码和寄存器回写等操作。 总的来说:C 的表达式运算模型默认以 int(或更宽类型)为最小运算单位,小整数类型主要用于存储,不用于算术。

3.1 乘法的坑

以下代码看起来没啥问题

1uint8_t a = 200;
2uint8_t b = 200;
3
4uint16_t result = a * b;

实际上如果在一个 MCU 上,其 int 类型为 16 bits,就会存在问题。因为 200 x 200 = 40000 超过了 int 所能表示的范围(-2^15 ~ 2^15 - 1 = 32,767)。左边明明有能力保存,但是左边计算已经出错了。

3.2 按位取反的坑

1uint8_t a = 0x0F;

你可能觉得 ~a 的结果是 0xF0,但实际上是 0xFFFFFFF0。很多时候 ~a 的结果看起来正确,这是因为赋值给 uint8_t 只保留了低 8 位。

3.3 左移的坑

对于以下转换,直觉是 0b1000'0000 << 1 得到 0b0000'0000

1uint8_t x = 0x80;
2
3uint8_t y = x << 1;

但是实际上 0x80 被提升成了 int, 即 0x80 << 1 得到 0x100,赋值给 uint8 只保留了低位。所以对于下面这种写法,我们就需要小心了。

1printf("%d\n", x << 1); // 此处输出 256

所以在嵌入式编程里,需要特别小心以下情况:

代码形态应该立即想到
uint8_t + uint8_t实际通常是 int + int
uint8_t * uint8_t中间结果是否会超 int?
~uint8_t~ 通常作用于 int
uint8_t << n左移通常是在 int 上
1 << n1 是 int,是否应该 1U?
uint16_t 算术16/32-bit MCU 上 promotion 可能不同

4. 类型转换

C 的转换分两类:隐式转换(赋值、初始化、传参、返回、混合类型运算时自动发生)和显式转换(强制转换,写作 (type)expr)。转换只负责把一个类型域里的值映射到另一个类型域,它不做范围检查:能编译不等于值还在预期范围内。

从 Java 过来必须重置的直觉

Java 的整数语义是定义完备的:byte/short 参与运算先提升为 int(和 C 一样),窄化赋值必须强转否则编译报错,溢出回绕有定义,数组越界抛异常,double 转 int 会饱和到 Integer.MIN_VALUE/MAX_VALUE。 C 里:窄化隐式也会发生(可能只有警告)、有符号溢出是 UB、浮点转整数越界是 UB、越界访问没人替你检查。C 的类型系统不提供任何运行时保护。

4.1 转换发生在哪些地方

上下文例子转换方向编译器态度
赋值 / 初始化uint8_t b = 300;窄化(模 2^8)-Wconversion 才警告
实参 → 形参形参是 int 时传 3.7浮点 → 整数同上
返回值int f(void) { return 3.7; }浮点 → 整数同上
算术运算u8a + u8b、int + unsigned整数提升 + 寻常算术转换默认不警告
printf 等变参printf("%d", long_val)默认实参提升默认不警告,且是 UB
条件上下文if (p)、if (x)标量 → 与 0 比较—
显式强转(uint8_t)x由你指定看转换是否可疑
指针 ↔ 整数(uintptr_t)p实现定义第 02 章讨论

其中算术那两行是自动且静默的,也最容易埋雷,先讲清楚。

4.2 混合类型运算:两步模型

int + unsigned int 算什么类型?答案不是"向宽类型转换",而是两步。

第一步:整数提升——只作用于 rank 低于 int 的类型(_Bool、char、short、位域、枚举):

所以 uint8_t/int8_t/uint16_t 在 32 位 int 平台上都先变成 int;而在 16 位 int 的平台上,unsigned short/uint16_t 提升为 unsigned int——这就是嵌入式里"promotion 行为不一样"的根源。

第二步:寻常算术转换——对剩下的操作数:

  1. 类型相同 → 直接算;
  2. 同为有符号或同为无符号 → 低 rank 转高 rank;
  3. 否则,若无符号操作数的 rank ≥ 有符号操作数的 rank → 有符号转无符号;
  4. 否则,若有符号类型能表示无符号操作数的全部取值 → 无符号转有符号;
  5. 否则 → 两者都转成"有符号操作数对应的无符号类型"。

三个能推翻"宽类型"直觉的例子:

表达式实际计算类型走哪条规则
int + unsigned intunsigned int规则 3:rank 相同,有符号转无符号
unsigned int + long(LP64)long规则 4:long 能装下全部 unsigned int
unsigned int + long(32 位 long)unsigned long规则 5:都装不下,一起转无符号

记忆口诀

先比 rank,rank 低的一方让位;rank 相同、或 rank 高的一方装不下对方的全部取值,就往无符号那边倒。

4.3 整数之间的转换

方向规则例子
→ 无符号(同宽或更窄)模 2^N,总有定义(uint8_t)300 == 44;(unsigned)-1 == UINT_MAX
→ 有符号,值可表示值不变(int)(short)-1 == -1
→ 有符号,值不可表示实现定义(C17 §6.3.1.3p3)(int8_t)200 == -56(GCC,补码回绕),但别依赖
同宽有符号 ↔ 无符号位模式不变,按新类型解释(int)0xFFFFFFFFu 在补码平台上为 -1,同样属实现定义

两个要点:

4.4 有符号与无符号转换的坑

典型例子如下:

1int temperature = -1;
2unsigned int threshold = 10;
3
4if (temperature < threshold) {
5  // 不会进入
6}

比较之前,temperature 转为 unsigned int,得到该类型的最大值。 实际应用里,size_t 特别容易带来这种问题:

1int index = -1;
2size_t length = 10;
3//要判断这个索引是否有效,应先排除负数:
4if (index >= 0 && (size_t)index < length) {
5  // 有效索引
6}

同一族的常见形态:

1size_t len = 0;
2len - 1;                              // SIZE_MAX:无符号下溢,不是 -1
3for (size_t i = n - 1; i >= 0; i--)   // i >= 0 恒真,n == 0 时先下溢,死循环
4strlen(a) - strlen(b) > 0             // 无符号差值:只有长度相等时为假,a 更短时同样为真
5(char)200 > 127                       // 结果取决于 char 符号性:ARM 默认为真,x86 为假

规范:可能为负的量用有符号类型,比较前先显式排除负值;长度、容量、下标用 size_t,并且先判断再减法(len > 0 ? len - 1 : 0)。

4.5 整数与浮点之间

1double d = 3.9;
2int i = d;        // 3:浮点转整数向零截断,不是四舍五入
3int j = -3.9;     // -3
4double back = i;  // 3.0
1if (d >= INT32_MIN && d <= INT32_MAX) {   /* NaN 与所有比较都为假,会被这里拦住 */
2    i = (int32_t)d;
3} else {
4    /* 报错或走饱和逻辑 */
5}

变参函数的默认实参提升(printf 系列):float 提升为 double,_Bool/char/short 提升为 int。所以:

1printf("%f\n", f);    // OK:float 已提升为 double
2printf("%d\n", l);    // 若 l 是 long:类型不匹配 = UB
3printf("%ld\n", l);   // 正确

4.6 显式转换:什么时候该用

强制转换表达了转换意图,不代表转换安全,也不等于范围检查:

1unsigned int input = 300;
2uint8_t value = (uint8_t)input;  // 44,不会报告“超出范围”

该用:

  1. 加宽后再运算:(uint32_t)a * b,避免中间结果溢出;
  2. 对齐接口宽度:memcpy(buf, src, (size_t)n);
  3. 匹配格式化输出:printf("%lu\n", (unsigned long)sizeof x)——%zu 是 C99 写法,但 MCU 上默认链接的 newlib-nano 只支持 C89 转换符,%zu 不可用,需先转 unsigned long 再用 %lu;
  4. 把意图写进代码:uint8_t v = (uint8_t)(x & 0xFF); 表示"我确实只要低 8 位"。

不该用:

可落地的顺序是:先检查范围 → 再运算 → 最后转换。

1/* 安全加法:用减法判断,避免 a + b 先溢出 */
2int add_u16(uint16_t a, uint16_t b, uint16_t *out)
3{
4    if (a > UINT16_MAX - b) {
5        return -1;
6    }
7    *out = (uint16_t)(a + b);
8    return 0;
9}

4.7 窄化

窄化(narrowing) 就是:一个值被转换到表示能力更弱的类型时,原来的值或信息可能无法完整保留。 “表示能力更弱”不只意味着位数少,还可能意味着:

1uint16_t x = 300;
2uint8_t y = x;

y 的结果是 44,因为无符号 8 位只能表示 0 - 255 的范围,这里高位做了截断。300 的二进制是 0001 0010 1100 ,截断后只保留低 8 位,即 0010 1100。

窄化本身不是错误,“没检查就窄化"才是。判断标准只有一句:值在目标类型的范围内吗?

编译器辅助:-Wconversion(窄化)、-Wsign-conversion(符号变化)能抓出一部分隐式转换,但它们不覆盖所有情况和所有路径,也不能替代运行时检查。固定宽度的接口上还可以用 _Static_assert 把假设钉死。

4.8 速查表:看到这些形态立刻想到什么

代码形态立刻想到为什么
uint8_t b = 300;隐式窄化 → 44,不报错赋值是隐式转换;转无符号按模 2^N 定义,300 mod 256 = 44。这不是错误,只是 -Wconversion 会警告
int i = -1; unsigned u = i;u == UINT_MAX同一条规则:-1 在模 2^32 下等于 2^32−1,即 UINT_MAX
if (t < 10u) 其中 t 是 int比较变无符号,t = -1 时为假寻常算术转换规则 3(见 4.2):unsigned int 的 rank ≥ int,所以 t 先转成 unsigned int,-1 变成 UINT_MAX,远大于 10
size_t n = 0; n - 1SIZE_MAX,不是 -1size_t 是无符号,减法按模 2^N 回绕,标准保证这个结果——这也是 4.3 说它"不会 UB"的另一面
for (size_t i = n - 1; i >= 0; i--)死循环无符号恒满足 i >= 0,条件永远为真;n == 0 时 n - 1 先回绕成 SIZE_MAX 起跳,i 减到 0 后再自减又回到 SIZE_MAX
(int)d 其中 d 是 double先检查范围,越界是 UB浮点转整数是向零截断;截断后的值不可表示(含 NaN)时行为未定义。Java 在这里饱和到 MIN/MAX_VALUE,C 不做兜底
printf("%d", long_val)变参类型不匹配 = UB变参不检查类型:%d 让 printf 按 int 读取一个 long 实参。默认实参提升只做"小整型 → int、float → double",不会为了让格式符匹配而收窄或加宽
(uint8_t)x 用来消警告强转 ≠ 检查强转只改类型、不做值域判断:(uint8_t)300 依旧是 44。警告的意思是"这里可能丢信息",不是"把类型写对让警告消失"
uint16_t r = a * b;先看 a * b 是在哪个类型上算的运算按操作数提升后的类型进行,赋值的目标类型救不回中间结果:uint8_t/uint16_t 先提升为 int,乘法在 int 上完成才截断给 uint16_t。超过 65535 静默回绕;16 位 int 平台上中间结果还会先溢出(UB)
char c = getchar();要用 int 接,否则判不出 EOFgetchar 返回 int,因为要同时表示全部字节值(0–255)和 EOF(负值)。存进 char 后:无符号 char 平台(ARM 默认)上 EOF 变 255,c == EOF 永远不成立;有符号 char 平台上 0x80–0xFF 的字节又可能与 EOF 混淆

5. 位运算

5.1 按位与或的妙用

5.1.1 用或将某一位置 1

该操作常用于启用某项功能。

1#define LED_ENABLE (1U << 3)
2
3REG |= LED_ENABLE;

其含义为:

1原来:1010 0001
2掩码:0000 1000
3      ---------
4结果:1010 1001

5.1.2 用与和取反将某一位清 0

该操作常用于关闭某项功能。

1REG &= ~(1U << 3);

左移 3 位:

100000001
2<< 3
3--------
400001000

取反:

1~00001000
2-----------
311110111

与上 REG:

1   10101101
2&  11110111
3-----------
4   10100101

5.1.3 用与检查某一位是否为 1

1#define UART_RX_READY (1U << 2)
2
3if (STATUS_REG & UART_RX_READY)
4{
5    // UART 收到数据了
6}

其含义为:

1STATUS_REG:  1010 0100
2MASK:        0000 0100
3             ---------
4结果:       0000 0100   != 0

说明 bit2 为 1。

5.3 右移与左移

5.3.1 右移

我们需要特别注意的是负数的右移。

  1. 无符号右移 >>:高位永远补 0 例如对于一个 8 位的无符号数来说:
1unsigned char x = 0b10010000;
2
3x >> 2;

结果:

1原来:10010000
2右移:00100100
3      ^^
4      补 0

所以无符号右移可以理解成, 在数学上除以 2^n 2. 有符号正数右移:通常和无符号一样

1int x = 16;
2
3x >> 2;   // 4
  1. 有符号负数右移
1signed char x = -16; // 11110000

对于 x >> 2,现代编译器通常执行算术右移(整体右移 n 位,左边补 n 个 1):

1原:11110000
2    >> 2
3果:11111100
4	^^
5	补符号位 1

5.3.2 左移

  1. 无符号左移 <<:高位丢弃,低位补零
1unsigned int u = 0x80000000u;
2u << 1; // 0,高位溢出丢弃

x << 1 超出的高位直接丢弃,左移一位相当于乘以 2,按照模 2^n 运算。以下例子在模 2^n 范围内:

100000101 //5
2       << 1
3---------
400001010     // 10
  1. 有符号左移
1unsigned int u = 1u;
2u << 31;   // 0x80000000,定义良好
3
4int i = 1;
5i << 31;   // 若 int 为 32 位,结果 0x80000000 不可表示,UB
6-5 << 1; // UB 

总的来说,位运算尽量 unsigned:右移补 0 可预测,左移溢出也有明确定义;signed 尤其涉及负数时容易踩坑。

5.3.3 位运算综合案例

一个典型的应用例子就是从寄存器里取出段的值。假设有如下 8 位寄存器:

1bit:   7 6 5 4 3 2 1 0
2REG:   1 0 1 1 0 1 1 0
3             └─┬─┘
4              MODE

规定 MODE 占 bit2 ~ bit4,其值是 101 也就是十进制 5。我们可以通过下面的算法获取到这个值。

1uint32_t mode = (REG >> 2) & 0x07;
  1. 先将 mode 位移到最末尾,需要右移 2 位
  2. 再与上一个末尾全是 1 的掩码 (000_0111),就会保护末尾低 3 位,而清除其他高位。