上一站:嵌入式转型的五个阶段(总览) 上一关:S0:一周把硬件点亮 这一篇:只做 S1 —— 用 C 写一个能网页远程开关灯的网关。 写给谁:S0 的 LED 已经自己点亮过,会点 C 语法但没写过「像样的 C 工程」的人。 耗时:4–6 周。


先说结论:这一关练的不是 C 语法

C 语法两周就能看完。但能把一个 C++ 网关改写成 C,靠的不是语法,是「知道那个抽象在解决什么问题」。

举个最典型的:

class Relay {
public:
    Relay(int pin)  { open(pin); }
    ~Relay()        { close(); }     // ← 析构函数自动断电
    void set(bool on);
};

这段代码真正表达的是「资源获取即初始化,离开作用域自动释放」,而不是「一个叫 Relay 的类」。

在 C 里,你要重新解决的是同一个问题:

不管从哪条错误路径退出,都保证把资源放掉。

C 的答案是 goto cleanup + 幂等的 close 函数。写出来长这样:

struct relay r = {0};
if (relay_open(&r, 17, "LED1", 0) < 0) goto cleanup;
if (relay_set(&r, 1) < 0)              goto cleanup;
cleanup:
    relay_close(&r);    /* 没打开也能安全调用,所以可以无条件执行 */

它长得完全不像原来的 C++,但它解决的是同一个问题。

这一关的核心心态,一句话: 不是「把 C++ 翻译成 C」,是「问这段代码在解决什么问题,然后用 C 的惯用法重新解决它」。

逐行翻译必失败——C 没有类、没有 STL、没有异常、没有模板。


一、六个抽象,六种 C 的表达

C++ 六个抽象在 C 里怎么表达

这张表是整篇的地图。后面几节只挑其中最难的三个展开(goto cleanup、函数指针、编译链接),其余的在成品代码里出现。

C++ 构造它在解决什么C 的替代
类 + 构造/析构资源生命周期自动管理struct + xxx_open() / xxx_close() + goto cleanup
虚函数 / 接口同一份代码处理不同设备函数指针表 struct sensor { int (*read)(void*, float*); void *ctx; }
std::string不怕写超、长度跟着走定长 char buf[N] + snprintf
std::vector容量不够自动扩定容数组 + size_t len(嵌入式不动态扩容)
std::thread同时处理多个客户端pthread_create / 或者 poll() 单线程(见第六节)
异常错误不能被忽略int 返回码(0 成功,负数失败)+ 每个调用都判

三条贯穿全篇的约定

1. 错误码约定:0 = 成功,负数 = 失败

int relay_open(struct relay *r, int offset, const char *name, int active_low);
/* 返回 0 成功;-1 参数错;-2 打开芯片失败;-3 取引脚失败;-4 申请输出失败 */

不要用正数表示错误(那是 Windows API 的风格),也不要混用——一套代码里两套约定,迟早判错。

2. 所有 close / destroy / free 必须幂等

int relay_close(struct relay *r) {
    if (!r || !r->is_open) return 0;    /* ← 没打开也算成功,这是关键 */
    ...
}

因为如果你要靠 goto cleanup 统一清理,就必须能无条件调用每一个 close。 漏了这一点,goto cleanup 立刻会变成 double-free。

3. 能静态分配就静态分配

嵌入式设备要跑几个月,堆碎片是定时炸弹。优先级:

全局 static 数组  >  栈上定长数组  >  malloc

二、goto cleanup:C 里最重要的一种代码结构

goto cleanup

C 里 goto 的名声很差(“GOTO 有害论”),但这一种用法是公认的正确做法,Linux 内核里到处都是。

2.1 一个真实的三资源场景

int start_gateway(void)
{
    int rc = 0;
    struct relay  r = {0};
    struct sensor s = {0};
    FILE         *cfg = NULL;

    if ((rc = relay_open(&r, 17, "LED1", 0)) < 0) goto cleanup;

    if ((rc = sensor_open(&s, "sht30")) < 0)      goto cleanup;

    cfg = fopen("/data/gw.conf", "r");
    if (!cfg) { rc = -10; goto cleanup; }

    if (load_config(cfg, &g_conf) < 0) { rc = -11; goto cleanup; }

    rc = run_event_loop();      /* 正常跑,出错也会落到 cleanup */

cleanup:
    if (cfg) fclose(cfg);
    sensor_close(&s);           /* 幂等,直接调 */
    relay_close(&r);            /* 幂等,直接调 */
    return rc;
}

和「每处 return 前各自清理」比,差在哪:

分散清理goto cleanup
清理代码份数N 个失败点 = N 份1 份
加新失败点要记得加清理什么都不用改
漏掉的后果文件描述符泄漏 → 跑几天报 Too many open files不会漏
顺序容易写反(应该反序释放)天然反序

漏一个 close 的症状非常隐蔽:程序当天正常,跑三天后开始报 Too many open files,然后一堆不相关的功能全挂。 这是「长期跑」类 bug 的典型形态——单次测试绝对测不出来。

2.2 为什么必须是「一个出口」

三个失败路径、三份清理代码,迟早会漏一处。而且漏了也不会立刻发现。

goto cleanup 把「清理」变成结构而不是纪律:

靠纪律的事一定会忘,靠结构的事不会。

2.3 一个必须注意的顺序

cleanup:
    if (cfg)  fclose(cfg);      /* 最先申请的,最后释放 */
    sensor_close(&s);
    relay_close(&r);            /* 最后申请的,最先释放 */

反序释放(后进先出),和 C++ 析构函数的调用顺序一致。

正序释放通常也能跑,但如果资源之间有依赖(比如 sensor 用的是 relay 那条线的上下文),正序就会访问已经释放的东西。


三、函数指针:这就是手写的虚函数表

函数指针 = 手写虚函数表

这是整篇最重要的一节。理解了它,你就理解了 Linux 内核为什么到处都是 struct file_operations。

3.1 先看 C++ 干了什么

class Sensor { public: virtual int read(float *out) = 0; };
class SHT30 : public Sensor { int read(float *out) override; };
class DHT22 : public Sensor { int read(float *out) override; };

Sensor *s = new SHT30();
s->read(&t);      // ← 运行时才知道调哪个

编译器在背后做了一件事:每个对象里藏一个指针,指向「这个类的函数表」。调用 read 时查表取地址。这张表叫 vtable。

你从没写过它,但它一直在。

3.2 C 里把它写出来

/* sensor.h */
#ifndef SENSOR_H
#define SENSOR_H

struct sensor {
    const char *name;
    /* ↓ 这就是虚函数:一个函数指针 */
    int (*read)(void *ctx, float *temp, float *rh);
    /* ↓ 这就是 this 指针 */
    void *ctx;
    int   is_open;
};

int sensor_open (struct sensor *s, const char *type);
int sensor_read (struct sensor *s, float *temp, float *rh);
int sensor_close(struct sensor *s);   /* 幂等 */

#endif
/* sensor.c */
#include "sensor.h"
#include <string.h>

/* ---- 具体实现一:SHT30 ---- */
static int sht30_read(void *ctx, float *temp, float *rh)
{
    (void)ctx;
    /* 真正的 I2C 读取,见 S2 第四节 */
    *temp = 25.3f;
    *rh   = 58.1f;
    return 0;
}

/* ---- 具体实现二:假传感器(示例/测试用) ---- */
static int fake_read(void *ctx, float *temp, float *rh)
{
    (void)ctx;
    *temp = 20.0f;
    *rh   = 50.0f;
    return 0;
}

/* ---- 注册:这一步就是「填 vtable」 ---- */
int sensor_open(struct sensor *s, const char *type)
{
    if (!s || !type) return -1;
    memset(s, 0, sizeof *s);

    if (strcmp(type, "sht30") == 0) {
        s->name = "SHT30";
        s->read = sht30_read;        /* ← 填表 */
    } else if (strcmp(type, "fake") == 0) {
        s->name = "FAKE";
        s->read = fake_read;         /* ← 填表 */
    } else {
        return -2;                   /* 不认识的类型 */
    }

    s->ctx     = NULL;               /* 如果有私有状态,在这里 malloc */
    s->is_open = 1;
    return 0;
}

/* ---- 调用:和虚函数一模一样 ---- */
int sensor_read(struct sensor *s, float *temp, float *rh)
{
    if (!s || !s->is_open || !s->read) return -1;
    return s->read(s->ctx, temp, rh);   /* ← 通过函数指针调用 */
}

int sensor_close(struct sensor *s)
{
    if (!s || !s->is_open) return 0;    /* 幂等 */
    s->read    = NULL;
    s->is_open = 0;
    return 0;
}

3.3 三项对应关系(记住这张对照)

C++C
虚函数声明 virtual int read(float*)函数指针成员 int (*read)(void*, float*)
隐式 this显式 void *ctx
vtable 由编译器自动生成在 xxx_open() 里手动填

这三项一一对应,没有任何神秘之处。

懂了这一条,Linux 内核的代码就不再是天书:

struct file_operations {
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    int     (*open) (struct inode *, struct file *);
    ...
};

这就是一张 vtable。 内核里每个驱动填一张,open("/dev/xxx") 时按表分发。 C++ 把它藏起来,C 让你写出来——写出来才真的懂。

3.4 一个容易忽略的细节:为什么需要 ctx

上面的例子没用到 ctx,但真实场景必须有。

/* 每个 SHT30 实例需要自己的 I2C 句柄 / 地址 */
struct sht30_priv {
    int  i2c_fd;
    int  addr;
    char bus[16];
};

static int sht30_read(void *ctx, float *temp, float *rh)
{
    struct sht30_priv *p = ctx;      /* ← 这就是 this */
    return i2c_read_sht30(p->i2c_fd, p->addr, temp, rh);
}

没有 ctx,「函数指针」就只能是无状态的。 而真实设备都有状态(地址、句柄、校准值)。

如果你的函数指针没法知道「我是哪个实例」,那设计就错了,而不是 C 做不到。


四、缓冲区安全:为什么 strcpy 是原罪

C 里最致命的一类 bug 是缓冲区溢出,而它们几乎全部来自三个函数:

函数问题替代
strcpy不检查目标长度snprintf(dst, sizeof dst, "%s", src)
strcat同上snprintf(dst + strlen(dst), ...) 或自己算长度
sprintf同上snprintf

4.1 一个看起来没问题的例子

/* ❌ SN 最长 64,这里一定溢出 */
char sn[64];
sprintf(sn, "device-%s-%s", vendor, model);

如果 vendor 是 40 字符、model 是 40 字符,你要写出 87 字节,缓冲区只有 64。

溢出的部分会覆盖栈上相邻的数据——可能是返回地址,可能是别的变量。症状:程序不崩,但某些变量莫名其妙变了,或者函数返回后跳到随机地址。

这类漏洞占了历史上安全漏洞的很大一部分,而且 30 年后仍然在出。

4.2 snprintf 的一个大坑

char buf[16];
int n = snprintf(buf, sizeof buf, "%s", very_long_string);
/* n = 本应写入的长度(可能 >> 16),不是实际写入的长度 */

snprintf 返回的是「本来想写多少」,不是「实际写了多少」。 所以:

if (n >= (int)sizeof buf) {
    /* 被截断了,数据不完整 —— 必须处理! */
    return -1;   /* 或者按你的策略:截断报错、扩容、拒绝 */
}

不判这个返回值,等于用了 strcpy。 很多人换了 snprintf 却不判返回值,溢出是被挡住了,但数据静默被截断——更隐蔽。

4.3 一条工程规矩

/* 项目里统一用这个宏,避免 sizeof 写错 */
#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))
#define SAFE_SNPRINTF(dst, fmt, ...) \
    snprintf((dst), ARRAY_LEN(dst), (fmt), __VA_ARGS__)

为什么需要它: 传 char * 给 snprintf 时,sizeof 得到的是指针大小(8),不是数组大小。这是最容易犯的一个错,而且编译器不报。

规则:凡是 snprintf 的 size 参数,只用 sizeof 数组名,绝不用 sizeof 指针。 如果传进来的是指针,那就必须额外传一个长度参数——这就是为什么好的 C API 签名通常是 (buf, len, ...) 而不是只有 (buf, ...)。


五、从源码到进程:编译、链接、加载各管什么

编译链接加载

这一节解决一个很实际的问题:报错信息看不懂,不知道该改哪里。

5.1 四个阶段

a.c b.c  --[编译]-->  a.o b.o  --[链接]-->  app  --[加载]-->  进程
阶段工具它干的活
编译gcc -c每个 .c 单独变成 .o;此时不知道别的 .c 里有什么
链接ld(gcc 调它)把 .o 拼起来,把符号地址填上
加载内核 execve把 app 装进内存,接上动态库
运行—你的代码

5.2 四类报错,分属不同阶段

① 'foo' was not declared in this scope —— 编译期

编译器在这个 .c 里找不到 foo 的声明。原因:缺 #include,或者名字拼错。

注意:这时候别的 .c 里可能真有 foo 的定义,但编译器看不到——它一次只处理一个 .c。

② undefined reference to 'foo' —— 链接期

声明有了,但找不到实现。 三种原因:

  • 忘了把 foo.c 加进编译命令
  • 忘了 -lxxx
  • 库的顺序错了(-l 要放在用它的 .o 之后)

这是最容易搞混的一对。 记住一句: 编译期看「有没有声明」,链接期看「有没有实现」。

③ multiple definition of 'main' —— 链接期

两个 .c 里都有 main。常见于「把旧文件留在目录里,编译时用 *.c 全通配」。

④ error while loading shared libraries: libxxx.so.1 —— 运行期

编译链接都成功了,但在目标机器上找不到 .so。这只在交叉编译/部署到别的机器时出现——就是你自己的机器上有,板子上没有。

5.3 static 到底藏了什么

static 加在全局变量/函数上:改变的是「链接可见性」。

/* util.c */
static int helper(void);      /* 只有本文件能调 */
int public_api(void) { return helper(); }   /* 别人能调 */

别的 .c 里写 extern int helper(void); 也链接不到——符号根本不在 .o 的导出表里。

static 加在函数内局部变量上:改变的是「生命周期」。

int counter(void) {
    static int n = 0;    /* 只初始化一次,函数退出后值保留 */
    return ++n;
}

两个不同的 .c 里各有一个 static int counter,是两个互不相干的变量。 这不会冲突——因为 static 意味着它们是内部链接的。

工程价值:一个库想把内部函数藏起来,全部加 static 就够了。 命名加前缀(mylib_helper)只是约定,static 才是机制。约定靠人守,机制靠编译器守。

5.4 sizeof 为什么不是成员之和

struct s { char a; int b; };
printf("%zu\n", sizeof(struct s));    /* 输出 8,不是 5 */

因为 CPU 按 4 字节对齐访问更快,编译器在 a 后面塞了 3 字节填充。

偏移内容
0a(1 字节)
1–3填充(3 字节)
4–7b(4 字节)

为什么这一关要先说这个: 因为 S2 要直接按偏移读寄存器,填充会让你的偏移全错。

/* 需要精确布局时,强制紧排 */
#pragma pack(push, 1)
struct frame { uint8_t type; uint32_t len; };
#pragma pack(pop)

但注意:紧排的结构体在 ARM 上访问可能变慢,甚至在对齐要求严格的架构上出错。 只在协议报文这种「必须逐字节对应」的地方用。


六、并发:先别急着上 pthread

C++ 的 std::thread 一换就是 pthread。但在网关这个场景,多线程往往是错的答案。

6.1 三种处理多客户端的模型

模型做法适用
一连接一线程accept 后 pthread_create连接数少(< 100)
poll / select 单线程一个循环盯所有 fd连接数百,逻辑简单 ✅
事件驱动(epoll)内核事件通知连接数千

这个网关只有几个客户端(网页 + 几个节点),poll 单线程是最合适的。

6.2 poll 版本(推荐)

#include <poll.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define MAX_CONN 32

struct conn {
    int   fd;
    int   used;
    char  rbuf[512];
    int   rlen;
};

static struct conn g_conns[MAX_CONN];

int event_loop(int listen_fd)
{
    struct pollfd pfds[MAX_CONN + 1];

    for (;;) {
        int n = 0;
        pfds[n].fd = listen_fd; pfds[n].events = POLLIN; n++;

        for (int i = 0; i < MAX_CONN; i++) {
            if (!g_conns[i].used) continue;
            pfds[n].fd = g_conns[i].fd;
            pfds[n].events = POLLIN;
            n++;
        }

        int rc = poll(pfds, n, 1000);     /* 1 秒超时,方便做定时任务 */
        if (rc < 0) { perror("poll"); return -1; }
        if (rc == 0) { do_periodic_work(); continue; }   /* 超时:干定时活儿 */

        /* 有新连接 */
        if (pfds[0].revents & POLLIN) {
            int c = accept(listen_fd, NULL, NULL);
            if (c >= 0) conn_add(c);
        }

        /* 已有连接有数据 */
        for (int i = 1; i < n; i++) {
            if (pfds[i].revents & (POLLIN | POLLHUP | POLLERR)) {
                conn_handle(pfds[i].fd);
            }
        }
    }
}

为什么这里 poll 比 pthread 好:

pthread 多线程poll 单线程
数据共享要加锁,锁错了极难查不用锁,天然安全
内存每线程一个栈(默认 8MB)一份
调试竞态问题无法复现单线程可断点
适合CPU 密集 / 阻塞式 IOIO 密集、连接数中等 ✅

一句话判据:如果你的线程大部分时间在「等」,那就不该用线程。 网关就是典型的「一直在等数据来」。

6.3 什么时候必须用 pthread

  • 某个操作真的会阻塞很久(比如等一个慢速硬件),而你又不能接受主循环被卡
  • 需要利用多核做真正的并行计算

那时候的规矩: 共享数据必须加锁,且锁的粒度要小。

static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static struct shared_data g_data;

void update(int v) {
    pthread_mutex_lock(&g_lock);
    g_data.value = v;                 /* 临界区尽量短 */
    pthread_mutex_unlock(&g_lock);
}

七、完整骨架:一个能跑的最小网关

把上面所有东西组装起来。这段代码可以直接编译运行(需要 libgpiod)。

/* gw.c —— 最小可用网关:网页点按钮 → 继电器动作 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <poll.h>
#include <signal.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <gpiod.h>

#define PORT      8080
#define MAX_CONN  8
#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))

/* ---------- 1. 继电器(对应 class Relay) ---------- */
struct relay {
    struct gpiod_chip *chip;
    struct gpiod_line *line;
    int   is_open;
    int   is_on;
    int   active_low;
};

static int relay_open(struct relay *r, unsigned int offset, int active_low)
{
    if (!r) return -1;
    memset(r, 0, sizeof *r);
    r->active_low = active_low;

    r->chip = gpiod_chip_open_by_name("gpiochip0");
    if (!r->chip) return -2;

    r->line = gpiod_chip_get_line(r->chip, offset);
    if (!r->line) { gpiod_chip_close(r->chip); r->chip = NULL; return -3; }

    /* 上电默认「关」,安全第一 */
    if (gpiod_line_request_output(r->line, "gw-led", active_low ? 1 : 0) < 0) {
        gpiod_chip_close(r->chip); r->chip = NULL; r->line = NULL;
        return -4;
    }
    r->is_open = 1;
    return 0;
}

static int relay_set(struct relay *r, int on)
{
    if (!r || !r->is_open) return -1;
    int level = on ? 1 : 0;
    if (r->active_low) level = 1 - level;
    if (gpiod_line_set_value(r->line, level) < 0) return -2;
    r->is_on = on;
    return 0;
}

static int relay_close(struct relay *r)     /* 幂等 */
{
    if (!r || !r->is_open) return 0;
    relay_set(r, 0);
    gpiod_line_release(r->line);
    gpiod_chip_close(r->chip);
    r->line = NULL; r->chip = NULL; r->is_open = 0; r->is_on = 0;
    return 0;
}

/* ---------- 2. HTTP 响应 ---------- */
static const char *PAGE =
    "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
    "Connection: close\r\n\r\n"
    "<!doctype html><meta name=viewport content='width=device-width'>"
    "<h2>网关 S1</h2><p>灯状态:<b id=s>--</b></p>"
    "<p><a href='/on'>开灯</a> | <a href='/off'>关灯</a></p>";

/* ---------- 3. 主流程 ---------- */
static volatile sig_atomic_t g_stop = 0;
static void on_signal(int sig) { (void)sig; g_stop = 1; }

int main(void)
{
    int rc = 0;
    struct relay r = {0};
    int listen_fd = -1;

    signal(SIGINT,  on_signal);
    signal(SIGTERM, on_signal);

    /* ① 开继电器(GPIO17) */
    if ((rc = relay_open(&r, 17, 0)) < 0) {
        fprintf(stderr, "relay_open failed: %d\n", rc);
        goto cleanup;
    }

    /* ② 建监听 socket —— 复用地址,避免重启时 TIME_WAIT 报错 */
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); rc = -10; goto cleanup; }

    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof opt);

    struct sockaddr_in addr = {
        .sin_family      = AF_INET,
        .sin_addr.s_addr = htonl(INADDR_ANY),
        .sin_port        = htons(PORT),
    };
    if (bind(listen_fd, (struct sockaddr *)&addr, sizeof addr) < 0) {
        perror("bind"); rc = -11; goto cleanup;
    }
    if (listen(listen_fd, MAX_CONN) < 0) { perror("listen"); rc = -12; goto cleanup; }

    printf("gateway listening on :%d\n", PORT);

    /* ③ 事件循环 */
    struct pollfd pfd = { .fd = listen_fd, .events = POLLIN };

    while (!g_stop) {
        int n = poll(&pfd, 1, 500);
        if (n < 0) {
            if (errno == EINTR) continue;      /* 信号打断是正常的,不是错误 */
            perror("poll"); rc = -13; break;
        }
        if (n == 0) continue;

        int fd = accept(listen_fd, NULL, NULL);
        if (fd < 0) continue;

        char req[512] = {0};
        ssize_t got = read(fd, req, sizeof req - 1);
        if (got > 0) {
            /* 请求行形如 "GET /on HTTP/1.1",从第 4 字节开始是路径 */
            if (strncmp(req, "GET ", 4) == 0) {
                if (strncmp(req + 4, "/on ", 4) == 0)       relay_set(&r, 1);
                else if (strncmp(req + 4, "/off", 4) == 0)  relay_set(&r, 0);
            }
            if (write(fd, PAGE, strlen(PAGE)) < 0) { /* 忽略 */ }
        }
        close(fd);
    }

cleanup:
    if (listen_fd >= 0) close(listen_fd);
    relay_close(&r);            /* 幂等:没开也能调 */
    printf("gateway stopped, rc=%d\n", rc);
    return rc;
}

编译与验证

# 编译(注意 -Werror:警告当错误,这是硬要求)
gcc -std=c11 -Wall -Wextra -Werror -O2 -o gw gw.c -lgpiod

# 零警告才算过。有任何一条警告,先修完再往下

# 跑起来
./gw
# 另开终端
curl http://localhost:8080/on
curl http://localhost:8080/off

如果 -Werror 报了一堆警告: 那不是编译器刁难你,那些警告在嵌入式里全是随机崩溃——未初始化变量、隐式类型转换丢精度、返回值没用。

-Wall -Wextra -Werror 三个一起上。 只加 -Wall 会漏掉 -Wextra 覆盖的那一大批(未使用参数、符号比较、类型转换)。

一段必须说的实测记录

上面这段 gw.c 我真的编译运行过(用 libgpiod 桩替身代替硬件),第一次跑出来两个问题,都写在这里,因为它们正是这一关要教的东西:

问题 1:Ctrl+C 退出时退出码是 -13,不是 0。

gateway stopped, rc=-13
poll: Interrupted system call

原因:SIGINT 打断 poll(),poll 返回 -1,errno = EINTR。我原来的代码把「任何 < 0」都当成错误 —— 但信号打断不是错误,是正常流程。

修法:

int n = poll(&pfd, 1, 500);
if (n < 0) {
    if (errno == EINTR) continue;      /* ← 关键:不是错误,重来一次 */
    perror("poll"); rc = -13; break;
}

这个坑非常普遍:所有「可能被信号打断的阻塞调用」都有这个问题(read、write、accept、poll、select、nanosleep)。 症状是「能跑,但退出码不对」——测试时不看 $? 就发现不了。

问题 2:rc=-13 还有一层——退出时 relay_close 没被调用到。

不,这里其实是好的:goto cleanup 生效了,relay_close 确实执行了。但这提醒了一件事:如果当初用「分散 return」的写法,break 出循环的那条路径极可能漏掉清理。

修完之后实测:

gcc -std=c11 -Wall -Wextra -Werror -O2 -c gw.c    → 零警告
./gw                                               → listening on :8080
curl /on   → 200
curl /off  → 200
kill -TERM → gateway stopped, rc=0                → 干净退出

写「可以直接编译运行」之前,先真的编译运行一遍。 我这次就是靠实跑才发现 EINTR 那个 bug 的——只读代码是看不出来的。


八、排查清单

症状最可能原因怎么确认
undefined reference to 'xxx'忘了 .c 文件或 -l看链接命令里有没有那个文件/库
undefined reference 但明明有实现-l 放的位置在 .o 前面把 -l 挪到最后
'xxx' was not declared缺 #include补头文件
multiple definition of 'main'两个 .c 都有 maingrep -rn "int main" *.c
编译过了跑不起来交叉编译的架构不对file app 看是不是目标架构
程序不崩但变量莫名变了缓冲区溢出valgrind 或 -fsanitize=address
跑几天报 Too many open files某条错误路径漏了 closels /proc/<pid>/fd | wc -l 看是否单调涨
分段错误但看不出哪行空指针 / 野指针gdb ./gw → run → bt
有段错误但 gdb 正常未初始化变量,值刚好是 0-O0 -g 编译,或 valgrind
-Werror 编不过警告没修逐条修,不要加 -Wno-xxx 绕过
内存一直涨泄漏valgrind --leak-check=full ./gw

valgrind 和 -fsanitize=address 的分工: 前者能查泄漏,但慢(10~50 倍);后者快,能查越界和 use-after-free,但查不到泄漏。两个都要跑。


九、6 周怎么排

周目标手上要出的东西
1环境 + 点亮 LED(C 版)命令行跑一下,灯亮
2继电器封装 + goto cleanup用 -Wall -Wextra -Werror 零警告编过
3函数指针 + 传感器抽象换一个 type 就换一种设备,主流程不改
4socket + HTTP 服务浏览器打开页面,点按钮灯亮
5poll 事件循环 + 信号处理Ctrl+C 能干净退出,资源全释放
6valgrind + 72 小时连跑零泄漏、RSS 不涨

十、过门标准

三条硬指标:

  • gcc -std=c11 -Wall -Wextra -Werror 零警告(不是「加到 -Wno 之后零警告」)
  • valgrind --leak-check=full 零泄漏(definitely lost: 0 bytes)
  • 连续跑 72 小时:top 看 RSS 不涨、ls /proc/<pid>/fd | wc -l 数量不变

能力指标(能讲出来):

  • 能说出 goto cleanup 相对「分散清理」的两个好处
  • 能画出虚函数表和函数指针的对应关系(三个对应项)
  • 能解释 undefined reference 和 not declared 分别发生在哪个阶段
  • 能说清 static 加在全局函数上和加在局部变量上,改的是不同的东西
  • 能说清 snprintf 的返回值是什么(不是实际写入长度)

十一、这一关结束时你应该能回答

  1. class Relay 表达的核心是什么?C 里用什么替代?
  2. 为什么所有 close 函数必须幂等?
  3. C 的「多态」是怎么实现的?void *ctx 对应 C++ 的什么?
  4. undefined reference 和 was not declared 分别在哪个阶段报?
  5. snprintf 返回「本来想写多少」,为什么这个设计反而更安全?
  6. poll 单线程和多线程各自什么时候更合适?

答不出第 1、3 题,说明还停在「翻译语法」,没到「重新解决同一个问题」。 而这正是 S1 唯一想教的东西。


下一站

S1 收口后,你手上第一次有了一个自己写的、能跑、能交付的 C 程序。

S2 把它推到没有操作系统的地方——那才是思维方式真正断层的地方。


参考

libgpiod、POSIX poll / pthread、snprintf 与结构体对齐语义依据各自手册与 C11 标准;C 语言基础与「指针即内存布局」的讲法参考《程序设计基础(C语言)》与《嗨翻C语言》。代码、四张图、排查清单、排期与过门标准均为原创整理,其中 gw.c 是完整可编译的示例,可直接用于教学。