C foundations / Lesson 11

读懂每一个二进制位,
再用掩码控制它。

一个整数可以保存多个开关状态。本课用普通变量模拟状态寄存器,从二进制和位运算出发,练习设位、清位、翻转和检查,并认识真实外设的边界。

11 / 位运算建议先完成结构体与枚举普通变量模拟 · C11
01 / 目标

为什么一个数可以表示多个状态?

假设一个设备既要记录“准备完成”,又要记录“是否故障”和“指示灯是否打开”。可以使用三个变量,也可以让一个无符号数的三个二进制位分别表示它们。本课先练习普通变量中的位操作,逐步建立读懂外设寄存器的基础。

  • 从最低位开始编号,读懂二进制位置与十六进制数值的关系。
  • 解释 &、|、^、~ 并手算简单结果。
  • 使用掩码完成设位、清位、翻转和测试;区分检查任意一位与全部指定位。
  • 描述合法移位范围、整数提升及 & 和 && 的差异。
本课产出

编译并运行 lesson11.c,逐行对照模拟状态的变化;写出每个掩码选中哪一位。

02 / 表示

从右边的第 0 位开始数

二进制每一位只能表示 0 或 1。从最低位开始编号:第 0 位权重是 1,第 1 位是 2,第 2 位是 4,依次乘 2。下面只画出低 8 位;整数实际宽度由类型和实现决定。

位编号:     7 6 5 4  3 2 1 0
位权重:   128 64 32 16  8 4 2 1
数值 5:     0 0 0 0  0 1 0 1
                           ↑   ↑
                         bit 2 bit 0

第 2 位和第 0 位为 1,因此数值是 4 + 1 = 5。十六进制每一位对应四个二进制位:低 8 位的 0000 0101 对应 0x05。C11 例程使用 0x05u 这类十六进制写法;上面的二进制位图只是说明,不依赖编译器对 0b 的扩展支持。

后缀 u 用于写无符号整数字面量。本课状态和掩码采用 unsigned int,避免把有符号数的负值移位规则混入基础练习。

03 / 例程

用普通变量模拟三个状态

创建 lesson11.c。先看三个掩码分别选中第几位,再预测每行结果。

lesson11.c完整程序 · C11
#include <stdio.h>

int main(void)
{
    unsigned int status = 0u;
    const unsigned int ready = 1u << 0;
    const unsigned int fault = 1u << 1;
    const unsigned int led = 1u << 2;

    status |= ready;
    status |= led;
    printf("set=0x%02X\n", status);
    printf("ready=%d, fault=%d\n",
           (status & ready) != 0u, (status & fault) != 0u);

    status &= ~ready;
    printf("clear=0x%02X\n", status);

    status ^= fault;
    printf("toggle=0x%02X\n", status);
    printf("selected=0x%02X\n", status & (fault | led));
    printf("low8_not_fault=0x%02X\n", (~fault) & 0xFFu);
    return 0;
}
Windows · MSYS2 UCRT64

编译并运行

gcc -std=c11 -Wall -Wextra lesson11.c -o lesson11.exe
./lesson11.exe
Ubuntu

编译并运行

gcc -std=c11 -Wall -Wextra lesson11.c -o lesson11
./lesson11

%X 用大写十六进制输出 unsigned int;02 表示至少两位并在必要时补零,并不截断更大的结果。比较表达式的结果类型是 int,因此用 %d 输出 0 或 1。

set=0x05
ready=1, fault=0
clear=0x04
toggle=0x06
selected=0x06
low8_not_fault=0xFD

这里的 status 是普通局部变量,并不连接到 STM32 的任何外设。每次打印的是模拟计算结果。

04 / 操作符

每一位分别计算

下面的表针对两个输入位 A 与 B。按位操作会对整数对应位置逐位应用规则;它们只用于整数类型。

ABA & BA | BA ^ B~A 的该位
000001
010111
100110
111100

& 是按位与:两个位置都为 1 才得 1;| 是按位或:至少一个为 1 就得 1;^ 是异或:两位不同时得 1;~ 是按位取反:0 变 1,1 变 0。

低四位示意:  1010  (0xAu)
             & 1100  (0xCu)
             = 1000  (0x8u)

本页的位图用空格突出视觉分组;写进 C 程序时使用 0xAu & 0xCu。按位与的结果为 0x8u。

05 / 掩码

设位、清位、翻转和检查

掩码是用 1 标出关注位置的整数。例如 1u << 2 的低位示意是 0000 0100,只选中第 2 位。把 status 想成一组状态,mask 想成要操作的位置:

目标写法作用
设为 1status |= mask;掩码的 1 所在位置被置 1,其他位保持。
清为 0status &= ~mask;掩码的 1 所在位置被清零,其他位保持。
翻转status ^= mask;掩码的 1 所在位置由 0 变 1 或由 1 变 0。
测试是否为 1(status & mask) != 0u掩码选中任意位置为 1 时,结果为真。

例程设好 ready 和 led 后,低 8 位为 0000 0101;清除 ready 后变为 0000 0100;翻转 fault 后为 0000 0110。翻转同一位两次会回到原状态;设位和清位则分别把选中位固定成 1 和 0。

如果掩码选中多位,(status & mask) != 0u 判断至少一位为 1;(status & mask) == mask 判断所有选中位都为 1。后一种写法在 mask == 0u 时也为真,使用前要根据业务判断零掩码是否有意义。

06 / 移位

移动几位之前,先确认范围

1u << 2 把无符号 1 向左移两位,得到 4,即 0x04u。0x20u >> 2 将 32 向右移两位,得到 8;无符号右移从高位补零。

在 C11 中,移位数量为负,或大于等于整数提升之后左操作数类型的宽度,都会造成未定义行为。不能依靠某个处理器或编译器碰巧给出的结果。无符号整数左移的结果按该类型的无符号范围处理;有符号负数移位和有符号左移超出可表示范围有额外规则,不应套用本课的例子。

unsigned int 的范围至少能表示 0 到 65535,示例中的位编号 0、1、2 以及下面的 0–7 均在范围内。若位号来自外部数据,先验证再移位:

unsigned int bit_number = 5u;
if (bit_number < 8u) {
    unsigned int mask = 1u << bit_number;
    /* 在此安全使用低 8 位掩码 */
}

对更宽的位字段,需要根据所用类型和目标平台明确可用位数。sizeof(type) * CHAR_BIT 是存储所占的总位数,某些 C 实现允许存在填充位,不能一概把它当作无符号数的有效值位数。具体嵌入式工具链与芯片手册是最终依据。

07 / 类型

为什么 ~ 不一定只翻转 8 位?

位运算之前,unsigned char、unsigned short 等较窄整数可能先经过整数提升:如果 int 能表示其全部值,就提升为 int;否则提升为 unsigned int。因此对一个窄类型写 ~value 时,运算结果的类型和位数不一定仍是原来的窄类型。

主例程把 fault 定义为 unsigned int。~fault 会对该类型的位取反,而不是只翻转人眼画出的低 8 位。再与 0xFFu 做按位与,才选出低 8 位:(~fault) & 0xFFu 得到 0xFDu。

同样,uint8_t 若由所用实现提供,参与表达式时也要考虑整数提升;不要仅凭变量名字中的 8 推断中间表达式始终是 8 位。将结果保存回窄类型时还会发生类型转换,需要核对目标类型和预期。

打印要匹配类型:例程所有传给 %X 的值均为 unsigned int;!= 结果为 int,用 %d。格式错误可能导致未定义行为,不能把警告当作显示样式问题。
08 / 比较

& 与 && 问的问题不同

& 比较每一个二进制位;&& 判断两个表达式是否都为真,并产生类型为 int 的 0 或 1。例如:

4u & 2u    /* 结果 0:0100 与 0010 没有共同的 1 */
4u && 2u   /* 结果 1:两个数都不是 0 */

&& 有短路规则:左侧为假时,不计算右侧;按位 & 需要计算两个操作数,并无同样的短路规则。检测某一位应写 (status & mask) != 0u,而不是 status && mask。

括号也很重要:status & mask == 0u 会先计算 mask == 0u,与预想的测试不同。明确写成 (status & mask) == 0u;遇到复杂表达式,优先让括号把计算顺序写清楚。

09 / 嵌入式

模拟状态,距离真实寄存器还有一步

本课变量允许普通地读取、计算、写回,因此 |= 和 &= 等操作可按上面的规则理解。真实外设寄存器可能存在特殊语义:例如某些状态位采用“写 1 清除”,或读取和写入会触发硬件动作。对这种寄存器直接套用普通变量的读改写,可能清除其他事件或产生额外副作用。

在 STM32 上使用任何寄存器之前,先查具体芯片的参考手册和寄存器位说明,确认可读写属性、复位值、写入语义、并发访问与厂商推荐方法。volatile 不能自动保证一组读改写是原子的,也不能替代这些核对。

先完成电脑练习

这节课没有访问硬件地址;请先能解释每一行的模拟值,再结合选定型号学习具体外设。

10 / 排错

看到意外的位,先检查这几处

现象可能原因检查方法
设置一位后其他状态丢失写成 status = mask,覆盖整个值。检查是否需要 status |= mask 保留其他位。
多次执行结果来回变化使用异或翻转,而不是设为 1。明确需要“固定”还是“取反”。
测试位的条件始终为真混用 && 或遗漏按位运算括号。写成 (status & mask) != 0u。
取反后高位也出现 1~ 作用于提升后或无符号类型的整个结果。先确认类型,再按需要用 & 0xFFu 取低 8 位。
移位行为异常位号超出类型允许范围或使用了有符号负数。先校验位号;掩码使用无符号类型,并核对目标平台。
11 / 练习

先画位图,再展开答案

练习 01 / 设位

保留已有状态并设置第 3 位

假设 status = 0x05u,执行 status |= (1u << 3); 后是多少?

查看参考答案

第 3 位掩码为 0x08u;0x05u | 0x08u = 0x0Du,原有第 0 和第 2 位保留。

练习 02 / 清除与翻转

区分清位和翻转

对同一个初始值 status = 0x06u,清除第 1 位与翻转第 1 位各得到什么?再对各自结果执行同一操作一次会怎样?

查看参考答案

第一次清除或翻转第 1 位都得到 0x04u。再清除一次仍为 0x04u;再翻转一次回到 0x06u。

练习 03 / 判断

检查一位,还是全部位?

已知 status = 0x04u、mask = 0x06u,分别判断 (status & mask) != 0u 和 (status & mask) == mask。

查看参考答案

与运算得到 0x04u。至少一位为 1,所以第一个条件为真;并非第 1、2 位都为 1,所以第二个条件为假。

练习 04 / 范围

外部位号应先做什么?

程序只允许操作低 8 位,收到位号 8 时,是否应直接执行 1u << bit_number?

查看参考答案

不要把它当作本题允许的掩码。先验证 bit_number < 8u;8 虽可能仍在该类型合法移位范围内,但超出了本题规定的低 8 位字段。

12 / 完成标准

能画、能算,也能解释条件

  1. 独立运行例程,能画出从 0x05 到 0x04 再到 0x06 的低位变化。
  2. 不看答案写出设位、清位、翻转、测试的四种写法,并解释多位掩码。
  3. 说明为什么移位前必须确认位号,为什么窄整数参与表达式时要注意提升。
  4. 区分 & 与 &&,说明真实寄存器为什么要先查手册。
资料

对照官方资料继续查

GNU 手册包含特定 GNU 实现的描述;可移植性判断应结合 C 标准和所用编译器。具体外设操作以芯片参考手册为准。