开始之前
hello、project-template 与 cmd-table。
你需要准备什么
编译器
本机 gcc 与 make;Makefile 里已有 CROSS_COMPILE,需要上板时再打开交叉前缀。
代码目录
chapters/ch02/code/:讲义跟做 hello/、project-template/、cmd-table/;实验闯关 fake-thermostat/。
编辑器
任意能改 C 的编辑器;改完 make,看终端输出是否符合预期。
本章你会学到什么
2.1 类型、变量、控制流
用 int / float / 比较与循环写出「温度高了就开风扇」这类判断。
2.2 函数、多文件、编译链接
声明与定义;Makefile 编多个 .c;.c → .o → 可执行文件;Linux 加载后先 _start 再 main。
2.3 指针、数组、结构体
看懂参数里的指针、配置结构体和数组序列。
2.4 后续实验会出现的 C
跟做切词与函数指针命令表;认位运算、宏与条件编译、设计化初始化。
thermo_decide,再改 thermo_read 的指针与错误分支。同一操作链不写两遍。
本章术语:先说清,再往下
下面这些词会在本章反复出现。先扫一遍,知道「它是什么、为什么需要它」;读到再见到就不慌,也可以随时翻回这一页。它们不是堆砌——每引入一个词,都是因为我们到了必须说清那一步的关口。
| 术语 | 是什么 | 为什么需要它 |
|---|---|---|
| 类型 | 规定一块内存按什么规则解释 | 决定运算方式和能存什么 |
| 变量 / 常量 | 可变的量 / 不可变的量 | 存数据 / 配参数(const) |
| 控制流 | 分支与循环 | 决定程序「先做什么、再做什么」 |
| 滞回 | 开/关用两个阈值,中间保持 | 温度在临界附近时风扇不频繁开合 |
| 函数 | 有名字的代码块(参数 + 返回值) | 把「一件事」包起来,复用、可测 |
| 声明 / 定义 | 告诉存在 / 给出实现 | .h 声明、.c 定义,分工 |
| 头文件 | 放声明与类型的文件(.h) | 多文件工程共享接口 |
| 编译 | 每个 .c → 对应 .o | 逐文件把 C 翻成机器码 |
| 链接 | .o + 库 → 可执行文件 | 把各文件的符号拼到一起 |
| _start / main | 进程的入口 | Linux 加载后先跑 _start 再进 main |
| 指针 | 存着某个对象地址的变量 | 函数要改调用方的变量、访问数据 |
| 数组 | 连续的同类型元素 | 存一串温度/读数 |
| 结构体 | 把相关字段捆成一种类型 | 一个对象传到底,不散落一堆变量 |
| 生命周期 / 存储期 | 变量活多久、放哪 | 全局/static/局部各不同,写前先想清 |
| 栈 / 数据段 | 局部变量放栈、全局放数据段 | 内存布局的心智模型 |
| 字符串 | char 数组 + 结尾 '\0' | C 没有字符串类型,命令解析全靠它 |
| 位运算 | & | << >> | 操作寄存器里的位(DHT22 读位) |
| 函数指针 | 指向函数的指针 | 命令表:名字 → 处理函数 |
| 宏 / 条件编译 | 编译期替换 / 按条件编译 | 模拟/真传感器一宏切换 |
| 设计化初始化 | .field = 值 初始化结构体 | 按字段名填,不易填错顺序 |
类型、变量、控制流
类型与变量
程序处理的是有类型的数据。类型规定一块内存按什么规则解释(整数、小数、文字等)。本课最常见的几种:
| 类型 | 典型用途 | 例子 |
|---|---|---|
int | 计数、状态、返回码 | 风扇开关 0/1、循环下标 |
float / double | 温度、小数 | 28.5 °C |
char / 字符串 | 日志文字 | "FAN ON" |
int 返回值 | 成功 / 失败 | 0 成功,非 0 失败 |
int fan_on = 0; /* 0 = 关,1 = 开 */ float temp_c = 27.5f; /* 当前温度 */ const float T_HIGH = 28.0f; const float T_LOW = 26.0f;
整数除法直接丢掉小数。算"温度占比"时,两边都是 int,结果会截断:
int temp_i = 27; int max_i = 30; int pct = temp_i * 100 / max_i; /* 90,不是 90.0 */ float ratio = (temp_i * 100.0f) / max_i; /* 90.0f:先变成浮点再除 */
想让结果保留小数,运算里至少一个操作数要是浮点(写 100.0f 或显式 (float))。
const 写清含义;开关语义用 0/1,并在注释里标明「开/关」。
控制流:判断与循环
控制流决定程序「先做什么、再做什么」——分支、循环都算。
温控里会用到滞回:开和关用两个不同的阈值,中间一段「保持上一状态」,避免温度在临界附近来回抖动时风扇频繁开合。例如:温度严格大于 28 °C 才开风扇,严格小于 26 °C 才关;落在 26–28 之间则不改当前开关状态。
if (temp_c > T_HIGH) { fan_on = 1; } else if (temp_c < T_LOW) { fan_on = 0; } /* 落在 [T_LOW, T_HIGH] 之间:保持现状(滞回) */
周期性采样用循环:
for (int i = 0; i < n; i++) { /* 读一次温度,做一次决策,打印一行 */ } while (running) { /* 主循环:采样 → 决策 → 延时 */ }
读图:从 25 升到 27 仍关着(还没越过 28);到 29 才开;再降到 27 仍保持开(还没低于 26);到 25 才关。中间「保持」就是滞回在起作用。
跟做与练习
- 写出:温度严格大于 28 开风扇,严格小于 26 关风扇,中间保持原状态。
temp >= T_HIGH与temp > T_HIGH在边界28.0上行为差在哪里?- 若把
T_HIGH和T_LOW写反,会出现什么现象?
$ cd chapters/ch02/code/hello $ make $ ./hello
函数、头文件与多文件工程
函数:把「一件事」包起来
函数是一段有名字的代码块,有参数、返回值和函数体。本课常见写法:
/* 返回 0 成功,非 0 失败 */ int read_temp(float *out_c); void apply_fan_policy(float temp_c, int *fan_on);
调用方根据返回值决定是否继续用读到的温度:
float t; if (read_temp(&t) != 0) { /* 读失败:打日志,不要用未初始化的 t */ return; } apply_fan_policy(t, &fan_on);
头文件:声明接口
头文件(.h)告诉其他 .c:「这些函数/类型存在」。实现放在对应的 .c 里。用 include guard 防止重复包含:
/* greet.h */ #ifndef GREET_H #define GREET_H void greet(const char *name); #endif
声明在 .h
函数原型、结构体类型、对外宏——给调用方看。
定义在 .c
函数体、静态内部变量——实现细节。
多文件工程:跟做模板
chapters/ch02/code/hello/— 单文件入门chapters/ch02/code/project-template/—src/+include/+build/
$ cd chapters/ch02/code/project-template $ make $ ./build/app
missing separator。
- 若只改
greet.c不改main.c,再make,哪些文件会重新编译? - 为什么调用方
#include "greet.h",而不是把函数体复制进main.c?
多文件工程里,函数和全局变量默认对外可见;加 static 后只在本 .c 里能用,别的文件同名也不冲突:
/* sensor.c */ static float last_temp = 0.0f; /* 别的文件看不到、改不到 */ static int calibrate(float t); /* 内部辅助函数,不外露 */
对外要用的才写进头文件;内部细节用 static 藏起来。
编译、链接与进程入口
多个 .c 最终变成一个可执行文件,分两步:
- 编译:每个
.c→ 对应.o(机器码 + 符号表:本文件提供了什么、还引用了什么)。 - 链接:多个
.o与 C 运行库拼成 ELF;解析未定义符号(如main.o需要的greet必须在greet.o里找到,否则报undefined reference)。
改 greet.c 通常只需重编 greet.o,不必重编 main.c。.h 不参加链接:它只在编译期把声明抄进需要它的 .c。
对你写的业务代码,入口是 main。对整个进程,Linux 加载 ELF 后先跑 C 运行库的启动代码(常记作 _start):准备栈、argc/argv、全局与静态初始化,再调用 main;main 返回后再做收尾并退出进程。
因此:应用层说「从 main 开始」;完整链条是 加载 → _start → 初始化 → main。
project-template;能口述 .c → .o → 链接 → ELF,以及 _start → main。
指针、数组、结构体
指针与数组
指针是「存着某个对象地址」的变量。函数要写回调用方的变量时,就传指针:
void set_fan(int *fan_on, int value) { if (fan_on == NULL) return; *fan_on = value; /* 改的是调用方那份变量 */ }
函数参数默认是拷贝。想改调用方的变量,就得传它的地址:
void bump(float t) { t += 1.0f; } /* 改的是拷贝,外面不变 */ void bump2(float *t) { *t += 1.0f; } /* 改的是外面那份 */ float now = 26.5f; bump(now); /* now 仍是 26.5 */ bump2(&now); /* now 变成 27.5 */
read_temp(&t) 传指针,就是为了把读到的温度写回调用方的 t。
数组是连续同类型元素。模拟温度序列常用数组驱动:
static const float seq[] = { 25.0f, 27.0f, 29.0f, 27.0f, 24.0f }; for (size_t i = 0; i < sizeof(seq)/sizeof(seq[0]); i++) { /* 用 seq[i] 当本轮温度 */ }
数组名在表达式中就是首元素地址(sizeof 除外)。所以下面两种写法等价:
float seq[] = { 25.0f, 27.0f, 29.0f }; float *p = seq; /* 指向 seq[0] */ float first = *p; /* 25.0f */
给函数传数组,实际上传的是指针——所以函数里 sizeof(arr) 拿不到数组长度,要另传一个长度。
NULL;数组下标不要越界。闯关脚手架里故意埋了相关坑。
结构体:把相关字段捆在一起
结构体把一组相关字段捆成一种类型,配置不必散落成一堆全局变量:
不打包就得三个全局变量、三个参数位置;打包成一个 cfg,一个对象传到底,改动跟着走:
float g_high = 28.0f; /* 不打包:三个散变量 */ float g_low = 26.0f; int g_ms = 2000; decide(&cfg, temp, &fan_on); /* 打包后:一个 cfg 传进函数,字段跟着走 */
要加一个字段,只在结构体里加一处;调用处基本不用动。
typedef struct { float t_high; /* 高于此开风扇 */ float t_low; /* 低于此关风扇 */ int period_ms; /* 采样周期(毫秒) */ } ThermoConfig; void decide(const ThermoConfig *cfg, float temp, int *fan_on) { if (temp > cfg->t_high) *fan_on = 1; else if (temp < cfg->t_low) *fan_on = 0; }
读结构体字段用 cfg->t_high(指针)或 cfg.t_high(变量本身)。用错字段名是真实 bug 的常见来源。
- 1看头文件 — 有哪些结构体、API、返回值约定。
- 2看 main — 输入从哪来、如何循环、打印什么。
- 3看实现 — 比较方向、字段名、空指针、错误分支是否完整。
- 4对照行为表 — 改一处、跑一次,输出是否逐步贴近验收表。
生命周期:变量活在哪、活多久
C 的变量按存储期分成几类。写代码前先想清楚一个问题:这个变量放在哪、活多久、谁看得见。
| 种类 | 放哪 | 活多久 | 谁看得见 |
|---|---|---|---|
| 全局变量 | 数据段(.data / .bss) | 整个程序运行期 | 所有文件(没加 static 时) |
| static(文件内或函数内) | 数据段 | 整个程序运行期 | 限定在声明它的文件 / 函数里 |
| 局部变量 | 栈 | 所在函数调用期间 | 只在本函数里 |
int g_samples; /* 全局:放数据段,程序结束才消失 */ static int g_seed = 42; /* static:放数据段,但只在本文文件可见 */ void loop(int n) { int local = 0; /* 局部:放栈,loop 返回就没了 */ }
这解释了两个常见现象:函数返回后还想用里面的局部变量不行——栈已经回收;全局计数在多次调用之间能记住值——数据段不随函数消失。static 局部变量同理:函数返回后它还在,只是只在函数内看得见。
实验直接要用到的 C:字符串、位运算、函数指针、宏
前几节是地基。这一节专挑本课实验代码里真正出现的几样东西讲深——它们不是课外延伸,第三章之后你就会亲眼看到。切词和命令表有一份能编译的跟做;其余配口头练习。
字符串:命令解析全靠它
C 没有「字符串类型」;字符串就是 char 数组,末尾一个 '\0' 表示结束。常用函数:
strlen(s):长度(不含结尾的\0)。strcmp(a, b):比较两串,相等返回 0。snprintf(buf, size, fmt, …):安全格式化(会截断,不会写越界)。sscanf(s, fmt, …):从字符串里按格式读数据。fgets(buf, size, stdin):读一行,含换行。
命令处理最常见的活是按空格切词:把 "set high 30" 拆成命令名 set 和参数 high 30。第四章的命令表就是这么干的:
char line[] = "set high 30"; char *sp = strchr(line, ' '); /* 找第一个空格 */ if (sp) { *sp = '\0'; args = sp + 1; } else args = line + strlen(line);
练习:对照 cmd-table/main.c 里的 dispatch,说明连续两个空格时 while (*args == ' ') 在干什么;若删掉这行,set temp 20 还会不会被 sscanf 认出来。
位运算:DHT22 读位靠它
位运算是按二进制位操作:&(与)、|(或)、^(异或)、<<(左移)、>>(右移)。
- 取某一位:
x & 0x80—— 只关心第 7 位是 0 还是 1。 - 置位:
data |= 1 << 3—— 把第 3 位置 1。 - 左移拼接:
v = (v << 1) | bit—— 一位一位把 0/1 拼成一个数。
第四章 DHT22 读 40 位数据,就是每读到一位 0/1,把它拼进字节:
data[j / 8] <<= 1; if (high_c > low_c) data[j / 8] |= 1; /* 高电平长=1,短=0 */
练习:给一个字节 b=0b10100110,说出第 3 位是 0 还是 1;再用位运算把 0x01 和 0x14 拼成整数 0x0114。
函数指针:命令表靠它
函数指针是「指向函数的指针」:把一个函数的地址存进变量,之后通过它调用。声明形如 int (*handler)(char *args);。
第四章的命令表就是一张「名字 → 函数指针」的表:
struct cmd_entry { const char *name; int (*handler)(char *args); }; static const struct cmd_entry cmds[] = { { "status", cmd_status }, { "set", cmd_set }, { NULL, NULL }, };
查表调用:遍历表,名字对上就用 e->handler(args) 调用对应函数。
跟做:把切词和命令表编出来
纸上认函数指针不够。把上面两段合进一份能运行的小程序:chapters/ch02/code/cmd-table/。它就是第四章命令表的缩小版——无 GPIO、无 select,只练「拆词 → 查表 → 调用」。
$ cd chapters/ch02/code/cmd-table $ make $ printf 'help\nstatus\nset temp 20\nstatus\nfoo\n' | ./cmd-table commands: help status set t_bar=28.0 t_bar=20.0 t_bar=20.0 unknown command: foo
打开 main.c,指认三处:strchr 切出命令名与参数;g_cmds[] 把名字绑到函数指针;dispatch 对上名字就 e->handler(args)。加一条命令只多一行表项,不必改查找循环——第四章的 TODO 就是在干这件事。
本步成功判据:输出与上面五条一致;能说出「加 help 为什么不用改 dispatch」。
make 后跑通命令表:t_bar 从 28.0 改到 20.0,foo 报 unknown command
gcc 复跑同一条命令,输出一致宏与条件编译:实验开关靠它
宏(#define)是编译期的文字替换;条件编译(#if / #ifndef)让一段代码在满足条件时才被编译进去。
#define SIMULATE_SENSOR 1 /* 1=模拟温度,0=真 DHT22 */ #if SIMULATE_SENSOR /* 模拟温度:程序自己变 */ #else /* 真 DHT22:读传感器 */ #endif
改一个宏、重新编译,程序就走另一套代码——第三章之后的实验开关全靠这个。
练习:解释「#ifndef + #define + #endif 的 include guard」为什么能防止一个头文件被重复包含。
设计化初始化:状态结构靠它
C99 允许按字段名初始化结构体,比按顺序填更不易错:
struct thermo_state g_st = {
.t_high = 28.0f,
.t_low = 26.0f,
.fan_on = 0,
};
第四章的状态结构就是这么初始化的。
练习:用 .field=值 补上 last_temp 和 has_sample;说明如果漏写一个字段,它会被初始成什么。
cmd-table;能在后续章节代码里认出切词、函数指针命令表、位运算、条件编译与设计化初始化。
lab.html 开始实验。