RISC-V Linux 嵌入式编程技术 · 第二章

够用的 C 语言基础

类型 · 控制流 · 函数与多文件 · 编译链接与进程入口
指针 · 结构体 · 字符串 · 位运算 · 函数指针 · 宏
— 2.1 / 2.2 / 2.3 / 2.4 —

跟做:hello + project-template + cmd-table 本章实验:写出滞回 + 改读数闯关

开始之前

前置:已完成第一章——能编译(板端原生或主机交叉)、荔枝派 4A 能运行程序。本章目标是读懂、改得动,并能按契约写出函数。讲义跟做 helloproject-templatecmd-table

你需要准备什么

编译器

本机 gccmake;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 加载后先 _startmain

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 = 值 初始化结构体按字段名填,不易填错顺序
2.1

类型、变量、控制流

类型与变量

程序处理的是有类型的数据类型规定一块内存按什么规则解释(整数、小数、文字等)。本课最常见的几种:

类型典型用途例子
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) {
    /* 主循环:采样 → 决策 → 延时 */
}
温度序列示例(滞回) T_HIGH = 28 T_LOW = 26 25 → 关 27 → 保持 29 → 开 27 → 保持 25 → 关 陷阱:比较方向写反、用错阈值字段,都会让「该开不开 / 该关不关」。

读图:从 25 升到 27 仍关着(还没越过 28);到 29 才开;再降到 27 仍保持开(还没低于 26);到 25 才关。中间「保持」就是滞回在起作用。

跟做与练习

  1. 写出:温度严格大于 28 开风扇,严格小于 26 关风扇,中间保持原状态。
  2. temp >= T_HIGHtemp > T_HIGH 在边界 28.0 上行为差在哪里?
  3. 若把 T_HIGHT_LOW 写反,会出现什么现象?
$ cd chapters/ch02/code/hello
$ make
$ ./hello
本节成果:能读懂阈值判断与简单循环;能说明滞回为什么需要「保持现状」。
2.2

函数、头文件与多文件工程

函数:把「一件事」包起来

函数是一段有名字的代码块,有参数、返回值和函数体。本课常见写法:

/* 返回 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

函数体、静态内部变量——实现细节。

多文件工程:跟做模板

project-template/ ├── Makefile ├── include/greet.h ← 接口声明 ├── src/main.c ← 调用 greet() ├── src/greet.c ← 实现 greet() └── build/ ← make 生成,可删
$ cd chapters/ch02/code/project-template
$ make
$ ./build/app
Makefile 命令行必须用 Tab 缩进,用空格会报 missing separator
  1. 若只改 greet.c 不改 main.c,再 make,哪些文件会重新编译?
  2. 为什么调用方 #include "greet.h",而不是把函数体复制进 main.c
static · 只在本文件可见

多文件工程里,函数和全局变量默认对外可见;加 static 后只在本 .c 里能用,别的文件同名也不冲突:

/* sensor.c */
static float last_temp = 0.0f;   /* 别的文件看不到、改不到 */
static int  calibrate(float t);  /* 内部辅助函数,不外露 */

对外要用的才写进头文件;内部细节用 static 藏起来。

编译、链接与进程入口

多个 .c 最终变成一个可执行文件,分两步:

编译 + 链接 main.c ──编译──► main.o (提供 main,需要 greet) greet.c ──编译──► greet.o (提供 greet) │ ▼ 链接 解析符号:main 需要的 greet ← greet.o 再链上 C 运行库(printf、启动代码等) │ ▼ build/app(可执行 ELF)

greet.c 通常只需重编 greet.o,不必重编 main.c.h 不参加链接:它只在编译期把声明抄进需要它的 .c

对你写的业务代码,入口是 main。对整个进程,Linux 加载 ELF 后先跑 C 运行库的启动代码(常记作 _start):准备栈、argc/argv、全局与静态初始化,再调用 mainmain 返回后再做收尾并退出进程。

进程启动 Linux 加载 ELF │ ▼ _start(运行库,不是你写的) ├─ 栈、argc/argv、环境 ├─ 全局 / 静态初始化 ▼ main(argc, argv) │ ▼ 返回后运行库收尾并退出进程

因此:应用层说「从 main 开始」;完整链条是 加载 → _start → 初始化 → main

本节成果:能说明声明/定义分工;能编译 project-template;能口述 .c.o → 链接 → ELF,以及 _startmain
2.3

指针、数组、结构体

指针与数组

指针是「存着某个对象地址」的变量。函数要写回调用方的变量时,就传指针:

void set_fan(int *fan_on, int value)
{
    if (fan_on == NULL)
        return;
    *fan_on = value;   /* 改的是调用方那份变量 */
}
传值 vs 传指针 · 哪个改到外面

函数参数默认是拷贝。想改调用方的变量,就得传它的地址:

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. 1
    看头文件 — 有哪些结构体、API、返回值约定。
  2. 2
    看 main — 输入从哪来、如何循环、打印什么。
  3. 3
    看实现 — 比较方向、字段名、空指针、错误分支是否完整。
  4. 4
    对照行为表 — 改一处、跑一次,输出是否逐步贴近验收表。

生命周期:变量活在哪、活多久

C 的变量按存储期分成几类。写代码前先想清楚一个问题:这个变量放在哪、活多久、谁看得见。

种类放哪活多久谁看得见
全局变量数据段(.data / .bss整个程序运行期所有文件(没加 static 时)
static(文件内或函数内)数据段整个程序运行期限定在声明它的文件 / 函数里
局部变量所在函数调用期间只在本函数里
内存里大致分三块 代码段 指令本身(只读) 数据段 全局 / static 变量(程序一开始就存在) 栈 每次调用函数临时分配,返回即回收
int g_samples;             /* 全局:放数据段,程序结束才消失 */
static int g_seed = 42;    /* static:放数据段,但只在本文文件可见 */

void loop(int n) {
    int local = 0;         /* 局部:放栈,loop 返回就没了 */
}

这解释了两个常见现象:函数返回后还想用里面的局部变量不行——栈已经回收;全局计数在多次调用之间能记住值——数据段不随函数消失。static 局部变量同理:函数返回后它还在,只是只在函数内看得见。

本节成果:能读指针写回、数组序列与配置结构体;能说清全局 / static / 局部各活多久、放哪。
2.4

实验直接要用到的 C:字符串、位运算、函数指针、宏

前几节是地基。这一节专挑本课实验代码里真正出现的几样东西讲深——它们不是课外延伸,第三章之后你就会亲眼看到。切词和命令表有一份能编译的跟做;其余配口头练习。

字符串:命令解析全靠它

C 没有「字符串类型」;字符串就是 char 数组,末尾一个 '\0' 表示结束。常用函数:

命令处理最常见的活是按空格切词:把 "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 读位靠它

位运算是按二进制位操作:&(与)、|(或)、^(异或)、<<(左移)、>>(右移)。

第四章 DHT22 读 40 位数据,就是每读到一位 0/1,把它拼进字节:

data[j / 8] <<= 1;
if (high_c > low_c) data[j / 8] |= 1;   /* 高电平长=1,短=0 */

练习:给一个字节 b=0b10100110,说出第 3 位是 0 还是 1;再用位运算把 0x010x14 拼成整数 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」。

x86 上编译运行 cmd-table:help、status、set temp 20、未知命令
图 3 在 x86 Linux 上 make 后跑通命令表:t_bar 从 28.0 改到 20.0,foo 报 unknown command
荔枝派上同样跑通 cmd-table
图 4 荔枝派上用板载 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_temphas_sample;说明如果漏写一个字段,它会被初始成什么。

本节成果:能编译并跑通 cmd-table;能在后续章节代码里认出切词、函数指针命令表、位运算、条件编译与设计化初始化。
下一步:打开 lab.html 开始实验。