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

GPIO 与执行器

外设与 I/O · gpiod 控继电器 · 决策—执行
用户态边界 · 寄存器位怎么翻
— 3.1 / 3.2 / 3.3 / 3.4 —

跟做:gpioset + 只读核对 本章实验:接线 + temperature_fan API:libgpiod

开始之前

本章要干什么:在荔枝派 4A 上,用 C 经 Linux 的 GPIO 接口改排针电平,经继电器驱动 5V 风扇。
你能学到什么:分层因果(程序 → libgpiod → 内核 → 引脚 → 继电器)、chip/line 寻址、申请与写入、用观察手段核对位翻转、滞回如何驱动执行链。
前置:已完成第一、二章——能编译 C(板端原生 gcc 或主机交叉编译都行)、SSH/SCP 可用。

你需要准备什么

编译

板端原生 gcc(最简单),或主机交叉工具链。见第一章。

荔枝派 4A + 套件

继电器模块(1 路 5V)、5V 风扇、面包板、杜邦线。继电器和风扇由课程提供。

代码

讲义跟做命令行 gpioset。代码在 chapters/ch03/code/

四节各自打透什么

3.1 外设与 I/O

物理量如何变成程序里的数;寄存器 / MMIO;用户态经内核接口访问硬件。

3.2 GPIO:认识与控制

chip/line、本板 1.8 V、gpiod 申请与写电平、只读核对、独占。

3.3 继电器、接线与跟做

信号端接线、gpioset 跟做、故障排查。

3.4 决策 → 执行

模拟温度与滞回如何驱动执行链。真传感器在第四章。

讲义与实验怎么衔接:讲义打透边界与信号链,并用 gpioset 控一次继电器。实验接上风扇负载,由你写出 line_request / fan_set / fan_decide,跑通模拟温度控制并改阈值。
术语表

本章术语:先说清,再往下

下面这些词会在本章反复出现。先扫一遍,知道「它是什么、为什么需要它」;读到再见到就不慌,也可以随时翻回这一页。它们不是堆砌——每引入一个词,都是因为我们到了必须说清那一步的关口。

术语是什么为什么需要它
外设CPU 之外的硬件模块(传感器、风扇、继电器)程序要控制的「外部世界」就是它
I/O程序与硬件的输入/输出把外界变成数、把数变成动作
GPIO可配置方向的引脚(输出高/低或读输入)用一根线的高低控制外部
寄存器芯片内部可读写的控制位芯片控制外设的唯一途径,一位一个开关
MMIO把寄存器映射到内存地址空间软件用「读写内存」的方式控制硬件
位(bit)二进制的一位 0/1寄存器里每个开关就是一 位
gpiochip一组 GPIO 控制器的设备节点(/dev/gpiochipNLinux 给一组脚统一编号、仲裁占用
line某个 chip 里的脚号(偏移)精确定位「哪一组、第几根脚」
电平引脚电压的高低(本板信号脚 1.8 V)「写 1/0」最终变成引脚上的电压
继电器小信号控制大电流通断的器件让 1.8 V 的 GPIO 能控制 5V 风扇
信号端 / 触点继电器输入侧 / 输出侧GPIO 只碰信号端,大电流走触点
NO / NC常开 / 常闭触点决定「无驱动时」风扇默认是关还是开
gpiod用户态 GPIO 库与命令行工具(libgpiod)走内核接口控制脚,不直接碰寄存器
consumer占用者的名字gpioinfo 里认出「谁占着我的脚」
独占一根 line 同一时刻只能一个申请者防止两个程序各写各的、互相踩
悬空引脚没有被任何程序驱动电平不定(常读到高),继电器可能乱吸合
滞回开/关用两个不同阈值,中间保持温度在临界附近时风扇不频繁开合
3.1

外设与 I/O

I/O:物理世界怎么进程序

外设是 CPU 之外、用来感知或驱动外部世界的硬件模块——传感器、LED、风扇驱动等都算。I/O(Input/Output)是程序与这些硬件打交道的总称:输入把外界变成程序里的数,输出把程序里的决定变成外界动作。

先把模型立住:外设 = 寄存器,控制 = 改位

芯片不直接理解「风扇开」。它只有地址和位:外设的每个功能都是一组寄存器,寄存器里的每一位是一个开关。要控制外部世界,程序最终做的是——往某个地址写一个位

第二章的位运算(& | <<)在这里落到硬件上:改寄存器的一位,就是控制物理世界的一根线。后面出现的 gpioset、gpiod、devmem 全在做同一件事——只是入口不同、离硬件远近不同。

物理世界 电气层 软件层 温度 / 按键 → 电压 / 电平 → 驱动读寄存器或缓冲 继电器 / 风扇 ← 高/低、开关 ← 程序写「开/关」

同一条 GPIO 脚,配置成输出就能驱动执行器(如继电器信号端);配置成输入就能读按键。对用户态程序来说,最终仍是「经内核接口写出一个命令」。下一节把这根脚在 Linux 里的名字(chip / line)和本板的 1.8 V 电平讲清楚——否则「写了 1 却不见动」会很难排查。

寄存器、MMIO 与用户态边界

寄存器是芯片内部一组可读写的控制位,用来配置外设、读状态、写输出。MMIO(Memory-Mapped I/O,内存映射 I/O)把这些寄存器映射到 CPU 可见的地址空间:驱动对某个地址的读写,实际落到外设控制逻辑,而不是普通 RAM。

说法含义本课怎么用
MMIO用「访存」方式访问设备寄存器建立因果链;业务程序经 gpiod / 内核访问
设备节点/dev/gpiochip5应用通过 open / 库 API 访问
内核驱动独占硬件、做权限与时序管理多进程仲裁、屏蔽芯片细节
应用 C 代码 │ gpiod_* / open / ioctl ▼ 设备节点(/dev/...) │ 系统调用 ▼ 内核驱动 ──► MMIO 寄存器 ──► 物理引脚 / 外设
边界:板上有 MMIO,但本课业务程序只走内核暴露的接口(gpiod)。在 set_value 之后,会用 devmem 只读核对某一位是否翻转——用来观察驱动确实写了寄存器,不当日常控外设手段。
电气边界:GPIO 只控信号端;风扇等大电流负载走继电器触点。荔枝派 4A 原生 GPIO 逻辑电平是 1.8 V。本课继电器是光耦隔离的,1.8 V 直连即可;需要 3.3 V 数据线匹配的模块到第四章再讲。
为何走内核接口:内核统一仲裁「谁占着这根脚」、屏蔽芯片细节、做权限管理。GPIO line 是独占申请的;业务路径保持 gpiod → 内核 → MMIO,多进程才能共用同一套规则。
3.2

GPIO:认识与控制

这一节把「外设」落到一根具体的脚上。读完应能回答:GPIO 是什么、Linux 怎么给脚编号、高低电平在本板上意味着什么、为什么要先申请再改电平,以及 set_value 之后如何用只读手段看见寄存器位翻转。

一根脚能做什么

GPIO(General-Purpose Input/Output,通用输入输出)是芯片上可以由软件配置方向的引脚。配置成输出时,程序决定它是高还是低,引脚电平驱动外部模块(继电器吸合、风扇转)。配置成输入时,程序读外部此刻是高还是低(按键有没有按下)。同一根物理脚,同一时刻通常只做一件事:要么输出,要么输入。

风扇、传感器模块内部可能很复杂;对 GPIO 这一层,常常仍然是「某一根脚输出高/低」或「某一根脚被拉高/拉低」。你的程序首先要管好自己的那根脚。

Linux 怎么称呼一根脚:chip 与 line

一块板子上往往有多组 GPIO 控制器。Linux 把每一组暴露成一个字符设备,名字像 /dev/gpiochip0/dev/gpiochip1……每一个叫一个 gpiochip(芯片控制器节点,不一定等于「整颗 SoC」)。

在某一个 chip 里面,脚按编号排列,这个编号叫 line(线号 / offset)。本课默认控继电器信号脚用的是:

#define GPIO_CHIP_PATH  "/dev/gpiochip5"
#define FAN_LINE        5    /* 对应排针丝印 IO1_5;换脚只改数字 */

chip 指出哪一组控制器,line 指出这一组里的第几根。只说「GPIO3」而不说 chip,在多控制器的板上会说不清到底是哪一根。

说法指什么本课例子
排针丝印硬件手册上的名字,接线时认这个IO1_5、IO1_6
/dev/gpiochipN内核暴露的某一组 GPIO 控制器gpiochip5
line 编号该 chip 内从 0 起的偏移line 5 → IO1_5
内核 debug 全局号/sys/kernel/debug/gpio 里的 gpio-651 一类基址 + line;编程宏里只写 line,全局号留给排查
本课默认与核对:排针 IO1_5 ↔ /dev/gpiochip5 line 5。日常编程用 chip + line/sys/kernel/debug/gpio 用来看占用与方向。gpiochipN 的 N 随内核探测顺序变化:本课 RevyOS 上排针在 gpiochip5;旧材料里的 gpiochip1 在本板上是另一组内部控制器。换镜像后,用运行时核对再写宏。
本板实测的编号

在本课 RevyOS 板上用 sudo cat /sys/kernel/debug/gpio 现查:gpiochip5 覆盖全局号 646–677(基址 646),所以 line 5 = gpio-651;它的父寄存器物理地址正是 0xffec006000——和讲义下面的寄存器地址对上了。编程只用 gpiochip5 + line 5,全局号 gpio-651 留给排查时对号。

排针丝印 IO1_5 │ ▼ /dev/gpiochip5 的 line 5 │ ▼ 内核里可能显示为 gpio-651(基址+5) │ ▼ 物理引脚电平 ←→ 继电器信号端

「高」「低」在本板上是什么意思

程序里写 1 / 0(或 active / inactive),最终变成引脚上的电压:

许多教材默认「GPIO = 3.3 V」。荔枝派 4A 的 SoC 原生 IO 为 1.8 V。排针上的 3.3 V / 5 V 电源脚是给外设供电的;信号脚仍是 1.8 V 域。

你接什么1.8 V GPIO 与模块的关系连接要求
本课继电器(5V 光耦隔离模块)输入侧是光耦 LED,1.8 V 足够触发直连 GPIO(IO1_5)
需要 3.3 V 电平的传感器(如第四章的 DHT22)信号电平要对齐 3.3 V第四章再讲电平转换
共地:板子的 GND 与外设 GND 连在一起。「高」「低」才有共同参考,现象才可复现。

本课继电器能直连,因为输入侧是光耦隔离:1.8 V 足以触发光耦;模块 5 V 线圈与输入信号在电气上不相通。第四章 DHT22 的数据线要 3.3 V 电平,那时再接电平转换。

经内核改电平:gpiod / libgpiod

在 Linux 用户态,日常业务经内核改芯片寄存器。你向内核请求:「使用 gpiochip5 的 line 5,配成输出,初始为低。」内核同意后,再请求设为高或低。这套用户态库和命令行工具统称 gpiod(库名常见 libgpiod,链接 -lgpiod)。

你的 C 程序 │ 打开 chip → 配置 line → 申请 → 写电平 → 用完释放 ▼ libgpiod │ 系统调用 ▼ 内核 GPIO 子系统 / 驱动 │ 真正写 MMIO、改引脚 ▼ 物理脚 ──► 继电器信号端 ──► 风扇电源通断

同一根脚可能被别的进程、某个内核驱动、或上次没退干净的命令占用。内核统一仲裁,才不会两个人各写各的。日常控继电器信号端走 gpiod;下面用 devmem 只读核对寄存器位,是旁路观察。

板端常见命令(本课板子多为 libgpiod 2.x):

许多板上没有名叫 gpio 的万能命令。需要权限时用 sudo,或把用户加入 gpio 组(以课程环境为准)。

/* libgpiod v2 调用轮廓 */
chip = gpiod_chip_open("/dev/gpiochip5");
/* 申请 line 为输出并给出初值 → 得到 line_request */
gpiod_line_request_set_value(req, line, 1);   /* 高:继电器吸合 */
gpiod_line_request_set_value(req, line, 0);   /* 低:继电器释放 */
gpiod_line_request_release(req);
完整示例:relay-toggle(libgpiod v2,本板编译运行通过)

把上面的调用轮廓合成一个完整可运行的程序。头文件 + main,关键步骤都有注释。这是演示 API 生命周期的平铺程序。

/* relay-toggle.c — 完整演示 gpiod 生命周期
 * 打开 chip → 申请 line 5 为输出(初值 0) → 拉高 → 拉低 → 释放 */
#include <gpiod.h>
#include <stdio.h>
#include <unistd.h>

int main(void) {
    /* 1. 打开 chip:IO1_5 所在控制器 */
    struct gpiod_chip *chip = gpiod_chip_open("/dev/gpiochip5");

    /* 2. 配置 line 5:输出,初值低。
     *    初值=低 很关键:不驱动 = 悬空 = 电平不定,继电器可能乱吸合 */
    struct gpiod_line_settings *st = gpiod_line_settings_new();
    gpiod_line_settings_set_direction(st, GPIOD_LINE_DIRECTION_OUTPUT);
    gpiod_line_settings_set_output_value(st, GPIOD_LINE_VALUE_INACTIVE);
    struct gpiod_line_config *cfg = gpiod_line_config_new();
    gpiod_line_config_add_line_settings(cfg, (unsigned int[]){5}, 1, st);

    /* 3. 申请(consumer="relay-toggle",gpioinfo 里认得出) */
    struct gpiod_request_config *rcfg = gpiod_request_config_new();
    gpiod_request_config_set_consumer(rcfg, "relay-toggle");
    struct gpiod_line_request *req = gpiod_chip_request_lines(chip, rcfg, cfg);

    printf("已申请 line 5,初值低 → 风扇确定关\n");
    sleep(1);

    /* 4. 拉高:继电器吸合,风扇开 */
    gpiod_line_request_set_value(req, 5, GPIOD_LINE_VALUE_ACTIVE);
    printf("line 5 = 高 → 继电器吸合,风扇开\n");
    sleep(2);

    /* 5. 拉低 → 6. 释放(把 line 还给内核) */
    gpiod_line_request_set_value(req, 5, GPIOD_LINE_VALUE_INACTIVE);
    printf("line 5 = 低 → 继电器释放,风扇关\n");
    gpiod_line_request_release(req);
    printf("已释放 line 5\n");
    return 0;
}
悬空:不驱动 = 电平不定

程序没运行时,没人申请 line 5——它悬空,电平由周围电路决定(常读到高)。如果继电器按 NO 接线,悬空高电平就把继电器吸合,风扇自己转起来。这正是「申请时给初值 0」要解决的问题:程序一启动就先把脚拉成确定的低,风扇才确定关着

本板实测输出
$ sudo /tmp/relay-toggle
已申请 line 5,初值低 → 风扇确定关
line 5 = 高 → 继电器吸合,风扇开
line 5 = 低 → 继电器释放,风扇关
已释放 line 5

set_value 之后:只读核对寄存器位

调用 gpiod_line_request_set_value(..., 1) 之后,真正改硬件的是内核 GPIO 驱动:经系统调用接到请求,往某个 MMIO 地址写一笔,把对应引脚那一位从 0 翻成 1(或反过来)。引脚电平变了,继电器才吸合。

① 应用:gpiod set_value(..., 1) │ 系统调用进入内核 ▼ ② 内核 GPIO 驱动写 MMIO(某一 bit) ▼ ③ 物理引脚电平变 → 继电器吸合/释放 旁路观察(只读,不当业务路径): 改电平前:devmem 读该寄存器 → 记下那一位 改电平后:devmem 再读同一地址 → 那一位翻转

本课板子(RevyOS)排针 GPIO 在 gpiochip5,数据寄存器物理地址 0xffec006000;IO1_5 是 gpiochip5line 5(内核全局号 gpio-651,基址 646 + 5)。寄存器地址和位是心智模型:改 0xffec006000 的第 5 位(值 0x20),就是改 IO1_5 引脚。

为什么不用 devmem 直接翻寄存器:现代内核默认开 CONFIG_STRICT_DEVMEM,用户态 devmem 对这个地址 mmap 会被拒(实测报 Operation not permitted)。这是内核刻意隔离硬件——正好印证 3.1 的边界:用户态不该绕过内核碰寄存器。核对电平,用内核自己的视图 debug/gpio

在荔枝派上实测(gpioset 控 IO1_5,看内核视角):

$ sudo gpioset -c gpiochip5 5=1
$ sudo cat /sys/kernel/debug/gpio | grep gpio-651
 gpio-651 (GPIO13              |gpioset             ) out hi
#         ↑ 名字             ↑ 占用者           ↑ out hi = 引脚高

$ sudo pkill -x gpioset
$ sudo cat /sys/kernel/debug/gpio | grep gpio-651
 gpio-651 (GPIO13              )

改电平前后,out hi 出现 / 消失——「程序 → 内核 → 引脚」这条链就验证过了。对照上表:占用者列出现 gpioset,就是它申请并占住了这根 line。

纪律:本课对 MMIO 只用 devmem 。业务控外设走 gpiod;读用来证明「程序 → 内核 → 寄存器位」这条链发生过。

独占与「Device or resource busy」

GPIO line 通常独占申请:同一时刻一个成功的请求占住这根脚,别人再申请或用另一种方式去读,就可能得到 Device or resource busy

课堂上常见情况:用 gpioset 试验后命令一直占着;随后跑程序或 gpioget 失败。先弄清占着,再结束那个进程或换另一根 line。同一根脚上保持一个占用者。

正常: 程序 A 申请 line 5 → 使用 → 释放 → 程序 B 再申请 占用未释放: gpioset 占着 line 5 │ ▼ temperature_fan / gpioget → busy(内核按规则拒绝)

在荔枝派上实测——占着 line 5 时,gpioget 读它直接失败:

$ sudo gpioget -c gpiochip5 5
gpioget: unable to request lines: Device or resource busy
$ ps aux | grep gpioset
$ sudo pkill -x gpioset   # -x:按进程名精确匹配
$ sudo cat /sys/kernel/debug/gpio
3.3

继电器、接线与跟做

这一节把 GPIO 接到真实的继电器与风扇上:信号端怎么接、用 gpioset 跟做一次、以及继电器不动时怎么排查。动手前先弄清面包板怎么连通,以及本章首次可能用到的电烙铁安全要求;完整步骤与插图见本章实验。

面包板与电烙铁(首次)

面包板:用语与商品详情一致——电源轨(「+」「−」横向连通)、连接孔(同一列纵向连通)、隔离凹槽(上下互不通)。摘录与结构图见 chapters/ch03/manuals/面包板_MB102摘录.md。把排针 5V / GND 接到电源轨后,再从轨上分线到继电器,可减少在排针上反复插拔。完整步骤与插图见本章实验。

面包板结构示意:电源轨、连接孔、隔离凹槽
图:面包板连通关系(实验图 4)。接线前断开开发板电源;勿将 5V 与 GND 短接。

电烙铁:本章以杜邦线插接为主;需要补焊端子或固定焊环时才使用。通电后烙铁放在架子上,勿用手试温,结束后断电冷却再收纳。安全细则与外形图见实验「电烙铁与安全」。

电烙铁外形示例
图:电烙铁外形示例(实验图 5)。焊接前确认开发板已断电。

继电器:信号端与负载端

继电器把小信号变成大电流通路的通断:GPIO 驱动信号端(丝印 IN / X1,高电平吸合);风扇等大电流走触点(NO / COM)。程序只写高/低,电机电流不进 SoC 脚。

对象GPIO 在做什么要建立的因果
继电器信号端输出高/低 → IN 有效 → 线圈侧动作讲义跟做打通这一段
风扇电源(触点)触点通断 5V实验接上 NO–COM 负载

跟做前:信号端三线

排针 IO1_5继电器 IN / X1
排针 5V继电器 V+ / VCC
排针 GND继电器 V- / GND

跳线帽拨到 H。此时即可听吸合或看工作灯;风扇电源回路放到实验再接。

跟做:用 gpioset 控一次继电器

目的:证明「chip + line → 引脚电平 → 继电器信号端」。

  1. 1
    确认脚
    本课默认:gpiochip5 line 5 = 丝印 IO1_5。若镜像不同,用 sudo cat /sys/kernel/debug/gpio 与 pinmux 现查。
  2. 2
    清占用
    sudo pkill -x gpioset(若有残留)。
  3. 3
    拉高 / 拉低
    sudo gpioset -c gpiochip5 5=1,观察继电器;再 5=0 释放。
  4. 4
    只读核对(建议)
    改电平前后各跑一次 sudo devmem 0xffec006000 32,对照第 5 位。
$ sudo gpioset -c gpiochip5 5=1
$ sudo cat /sys/kernel/debug/gpio | head -n 80
# 找到 gpiochip5 / line 5:应为 out hi
$ sudo pkill -x gpioset
# 释放(gpioset 是持线命令,进程活着就一直占着;用 Ctrl+C 或 pkill 释放)
gpioset 是持线命令:一条 gpioset ... 5=1 会一直占住 line 5 直到进程结束(Ctrl+C / pkill)。要"改状态 + 看状态",开两个终端:一个发 gpioset,另一个 cat /sys/kernel/debug/gpio 看变化。
为什么要 sudo?/dev/gpiochip* 默认归 root,普通用户打不开(Permission denied)。

故障排查(信号链)

继电器未动时,按软件状态 → 编号是否对应排针 → 信号端接线取证。

先查证据常见结论
进程与占用ps / gpioinfo / debug/gpio残留 gpioset;或写的是 0 却以为写了 1
chip / line 是否排针pinmux、debug/gpio旧材料写的 gpiochip1 在本课 RevyOS 上是内部控制器;排针在 gpiochip5
内核状态 vs 继电器line 为 out hi 且继电器未动查共地、5V、跳帽 H、IN 是否接到 IO1_5
某脚恒 out lo换 IO1_4/5/6 对比板级脚失效或硬下拉:换脚并记下
本课默认:丝印 IO1_5 ↔ gpiochip5 line 5。丝印是硬件事实;(chip, line) 随镜像变化——认脚认丝印,编号查运行时。
$ sudo cat /sys/kernel/debug/pinctrl/*/pinmux-pins
$ sudo cat /sys/kernel/debug/gpio
3.4

决策 → 执行:模拟温度怎么驱动风扇

数据流:决策 → 执行

第三章的程序用程序内部自变的模拟温度(约 24°C → 30°C 再降回)演示「决策 → 执行」:模拟温度 → 阈值判断 → 继电器 → 风扇。真传感器(DHT22)到第四章再接。

执行侧是开/关:GPIO 高/低 → 继电器 → 风扇电源通断。两档阈值形成滞回(第二章已定义:中间区间保持上一状态),温度在临界附近时风扇保持上一状态。

模拟温度 T(程序内部自变) │ ▼ 阈值判断 T > T_HIGH → 风扇开(继电器吸合) T < T_LOW → 风扇关(继电器释放) 中间区间 → 保持上一状态 │ ▼ gpiod 写 FAN_LINE(IO1_5) ──► 继电器信号端 ──► 风扇
/* 伪代码:方向示意 */
if (模拟温度 > T_HIGH) 风扇应开;
else if (模拟温度 < T_LOW)  风扇应关;
/* 中间区间:保持上一状态 */

两个阈值的作用:高开、低关,中间区间保持——真实温控普遍如此。默认 T_HIGH=29 / T_LOW=25(与实验学生版、图 3 一致)。

下一步:打开 lab.html 开始实验。