C foundations / Lesson 10

把有关的数据放在一起,
让状态有名字。

传感器的读数、阈值和状态本来属于同一个对象。用结构体把它们组织起来,再用枚举表达状态,并学习对象、指针和函数之间如何配合。

10 / C 语言基础建议先完成指针入门模拟数据 · C11
01 / 目标

把信息组织成一个完整对象

单独的 raw、threshold 和 state 可以表示一个传感器,但当传感器变多时,变量容易混淆。结构体让一组有关联的成员属于同一个对象;枚举给状态值起可读的名字。

  • 区分结构体类型、结构体变量与成员,能够初始化并读取成员。
  • 使用 . 和 ->,理解指针有效性与 const 的作用。
  • 跟踪结构体按值传递、函数返回结构体与枚举状态。
  • 说明结构体填充和字节序为什么使其不能直接当通用通信帧。
本课产出

一个创建模拟传感器、判断阈值并打印状态的 lesson10.c。数值是示例,程序没有读取真实硬件。

02 / 实验

创建并运行一个完整程序

先预测 raw=42、阈值为 40 时的状态,再保存以下内容为 lesson10.c:

lesson10.c完整例程 · C11
#include <stddef.h>
#include <stdio.h>

enum SensorState {
    SENSOR_READY = 0,
    SENSOR_HIGH = 1,
    SENSOR_ERROR = 2
};

typedef struct Sensor {
    int raw;
    int threshold;
    enum SensorState state;
} Sensor;

Sensor sensor_make(int raw, int threshold);
enum SensorState sensor_classify(const Sensor *sensor);

int main(void)
{
    Sensor sensor = sensor_make(42, 40);
    sensor.state = sensor_classify(&sensor);

    printf("raw=%d threshold=%d\n", sensor.raw, sensor.threshold);
    switch (sensor.state) {
    case SENSOR_READY:
        puts("state=READY");
        break;
    case SENSOR_HIGH:
        puts("state=HIGH");
        break;
    case SENSOR_ERROR:
        puts("state=ERROR");
        break;
    default:
        puts("state=UNKNOWN");
        break;
    }
    printf("code=%d\n", (int)sensor.state);
    return 0;
}

Sensor sensor_make(int raw, int threshold)
{
    Sensor result = { .raw = raw, .threshold = threshold, .state = SENSOR_READY };
    return result;
}

enum SensorState sensor_classify(const Sensor *sensor)
{
    if (sensor == NULL) {
        return SENSOR_ERROR;
    }
    if (sensor->raw >= sensor->threshold) {
        return SENSOR_HIGH;
    }
    return SENSOR_READY;
}
Windows · MSYS2 UCRT64

编译并运行

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

编译并运行

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

预期输出:

raw=42 threshold=40
state=HIGH
code=1

程序先生成一个结构体对象,再通过它的地址判断状态。输出部分用 switch 区分三种已列出的状态,并保留 default 处理未知状态。

03 / 追踪

跟着一次调用走一遍

  1. 01
    声明类型

    枚举列出具名状态常量;结构体定义说明每个传感器对象有哪些成员。

  2. 02
    创建对象

    sensor_make(42, 40) 创建局部结构体 result,设置两个整数和初始状态,再按值返回结构体。

  3. 03
    传递地址

    &sensor 是 main 中对象的地址。sensor_classify 收到一个指针,先检查是否为 NULL。

  4. 04
    读取并判断

    sensor->raw 为 42,sensor->threshold 为 40;42 大于等于 40,所以函数返回 SENSOR_HIGH。

  5. 05
    保存并输出

    sensor.state 接收返回值。状态与 SENSOR_HIGH 相等,所以显示 HIGH;该具名常量的示例值是 1。

不要只看名字:函数中的形参指针也叫 sensor,但它是独立的指针变量;main 中的 sensor 是结构体对象。两者的类型不同。
04 / 类型

先定义结构体,再创建变量

typedef struct Sensor {
    int raw;
    int threshold;
    enum SensorState state;
} Sensor;

struct Sensor 是结构体类型名,其中 Sensor 是结构体标签;花括号列出三个成员,每个成员有自己的类型和名称。结束花括号后还要写分号。这个定义描述对象的组成,本身不创建本例中的传感器变量。

这里的 typedef 为这个结构体类型引入别名 Sensor,因此 Sensor item; 与 struct Sensor item; 使用同一个结构体类型。typedef 不创建新变量,也不会自动转换数据或复制对象。

对照数组:数组各元素类型相同,按下标访问;结构体可组合不同类型的成员,按成员名访问。两者都可以组织数据,但适合表达的关系不同。
05 / 成员

初始化并用点号访问

Sensor sensor = { .raw = 42, .threshold = 40, .state = SENSOR_READY };
printf("%d\n", sensor.raw);
sensor.threshold = 45;

在 C11 中,.raw = 42 是指定成员的初始化写法;它清楚说明数值属于哪个成员。也可以按照成员声明顺序写 Sensor sensor = {42, 40, SENSOR_READY};。对象初始化时部分成员未列出,会按静态存储期对象的规则初始化;本课为清晰起见显式列出所有成员。

sensor.raw 用点号从结构体对象读取 raw;sensor.threshold = 45; 修改该对象的阈值。成员访问不会自动重新执行分类:如果阈值变化后还要更新状态,应按程序需求再次调用判断函数并保存结果。

06 / 指针

指向结构体时,用箭头访问成员

Sensor sensor = sensor_make(42, 40);
Sensor *p = &sensor;

p->threshold = 45;
printf("raw=%d\n", (*p).raw);

p 保存对象地址,p->threshold 等价于 (*p).threshold:先通过指针取得对象,再访问成员。括号不能随意去掉,因为 . 与 * 的结合方式不同。对结构体对象直接用 .,对有效的结构体指针可用 ->。

sensor_classify(const Sensor *sensor) 通过指针读取对象,const 表示不能通过这个指针修改所指对象的成员;它不保证原对象不会通过其他有效途径改变。函数先检查 NULL,但非空也不自动证明指针仍指向有效对象;调用方仍要保证指针的来源、类型和对象生命周期正确。

07 / 函数

按值传递,也可以按值返回

主例程中的 sensor_make 返回一个结构体值:函数内的局部对象在返回时提供其值,main 用该值初始化自己的对象。它没有返回局部变量的地址,因此返回后 main 中的对象仍可使用。

Sensor with_threshold(Sensor item, int threshold)
{
    item.threshold = threshold;
    return item;
}

/* 已有 Sensor sensor 时: */
Sensor changed = with_threshold(sensor, 50);

这里传入的是结构体值;item 是函数中的独立形参对象,修改其整数成员不直接改变调用方的 sensor。返回结果被保存到另一个对象 changed。也可以显式写 sensor = with_threshold(sensor, 50); 用返回值更新原对象。

结构体按值赋值会复制其成员的值:如果将来某个成员本身是指针,复制的是地址值,两个结构体中的指针仍可能指向同一对象;这不会自动复制指针所指的数据。通过有效指针修改该数据,也可能影响其他访问者看到的内容。

08 / 状态

给整数状态起有意义的名字

enum SensorState {
    SENSOR_READY = 0,
    SENSOR_HIGH = 1,
    SENSOR_ERROR = 2
};

enum SensorState 是枚举类型,花括号中的标识符是枚举常量。这里显式指定 0、1、2,便于阅读和调试;没有显式指定时,首个默认从 0 开始,后续未指定的值接在前一项之后递增。

枚举提供名称,却不会自动证明外部输入一定是列表中的某个状态。对串口、网络或其他不可信输入,应先验证取值,再决定如何解释;处理状态的 switch 可保留 default 分支,应对非预期值。一个状态对象的表示大小依具体实现,不能把它当作固定宽度的通信字段。

留意错误路径:主例程输入的是指向有效对象的指针,因此判断函数返回 READY 或 HIGH。传入空指针时函数返回 ERROR;输出分支也为 ERROR 和未知状态提供了明确处理。
09 / 布局

为什么不能直接把结构体当通信帧?

sizeof(Sensor) 的结果是该实现中对象占用的字节数,类型为 size_t,可以使用 printf("%zu\n", sizeof(Sensor)); 在本机查看。编译器为满足成员对齐等要求,可能在成员之间或结构体末尾加入填充;成员类型、大小、对齐与目标平台和工具链有关,不应把本机数字当成所有芯片的结果。

通信协议需要明确每个字段的位宽、编码、顺序和字节序;枚举的大小与结构体填充也不能作为协议约定。应依据协议逐字段编码和解码,校验长度与取值;如果使用 <stdint.h> 的定宽整数,也应核对目标实现提供了所需类型并自行规定字节顺序。

联系硬件:

结构体可以组织配置、采样值和状态,但不能仅凭一份普通 C 结构体推断某芯片寄存器的地址、宽度或访问语义。涉及内存映射寄存器时,应使用目标芯片厂商提供的定义并核对手册、工具链与读写限制。

10 / 排错

常见问题与检查方法

现象原因线索检查方法
成员访问编译报错对结构体指针用了 .,或对对象用了 ->先查变量类型;对象用点号,有效指针用箭头。
修改成员后状态没有变状态只是保存的字段,不会自动更新按需要再次执行分类并保存返回值。
函数调用崩溃或异常指针为空、失效或类型不正确追踪对象生命周期与地址来源,避免解引用无效指针。
通信双方解读不同直接发送结构体内存,忽略填充、宽度或字节序对照协议逐字段编码、校验并在两端测试。
状态显示不符合预期只处理了部分枚举值明确 ERROR 与未知值的处理路径。
11 / 练习

先预测,再改代码验证

LEVEL 01 / 判断

读数低于阈值

把主例程 sensor_make(42, 40) 改成 sensor_make(39, 40),预测输出的状态和编号。

查看参考答案

39 小于 40,返回 SENSOR_READY,因此显示 state=READY、code=0;第一行相应显示 raw=39 threshold=40。

LEVEL 02 / 成员访问

等价的两种写法

若 Sensor *p = &sensor; 且 p 有效,读取阈值的 p->threshold 还可以怎样写?

查看参考答案

(*p).threshold。先解引用指针得到结构体对象,再用点号访问成员;*p.threshold 不是同一个分组。

LEVEL 03 / 按值传递

修改副本会怎样?

在函数外创建 Sensor changed = with_threshold(sensor, 50);。如果忽略结构体内部的指针成员,本例调用完成后原对象 sensor.threshold 会自动变成 50 吗?

查看参考答案

不会。函数的结构体形参是独立对象,返回的新结构体初始化 changed;只有在调用方将返回值赋给 sensor 等操作后,原对象成员才会随之改变。

LEVEL 04 / 通信

状态来自串口怎么办?

收到一个未经验证的状态数字时,为什么不能只依靠枚举声明就认为它肯定是 READY、HIGH 或 ERROR?

查看检查要点

枚举为一些值提供名称,不能替代对外部输入的校验。应先检查字段宽度、长度和数值,再映射到具名状态,并设计未知值或错误路径。

12 / 验收

能够解释对象、地址与状态

  1. 能够写出一个结构体定义和一个初始化的对象,并区分类型、变量与成员。
  2. 能够解释 .、-> 和 const Sensor * 的用途。
  3. 能够手工追踪主例程的返回值,并完成四道练习。
  4. 能够说明枚举需要验证外部输入,以及为何不能把结构体内存直接作为通用通信格式。

下一课学习位运算,用普通变量模拟硬件寄存器中的位设置和检查。

资料

对照官方资料继续查

平台的类型大小、字节序和寄存器访问方式应以目标工具链、协议与芯片资料为准。