上一站:嵌入式转型的五个阶段(总览) 上一关: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 的表达
这张表是整篇的地图。后面几节只挑其中最难的三个展开(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 里最重要的一种代码结构
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 字节填充。
| 偏移 | 内容 |
|---|---|
| 0 | a(1 字节) |
| 1–3 | 填充(3 字节) |
| 4–7 | b(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 密集 / 阻塞式 IO | IO 密集、连接数中等 ✅ |
一句话判据:如果你的线程大部分时间在「等」,那就不该用线程。 网关就是典型的「一直在等数据来」。
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 都有 main | grep -rn "int main" *.c |
| 编译过了跑不起来 | 交叉编译的架构不对 | file app 看是不是目标架构 |
| 程序不崩但变量莫名变了 | 缓冲区溢出 | valgrind 或 -fsanitize=address |
跑几天报 Too many open files | 某条错误路径漏了 close | ls /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 就换一种设备,主流程不改 |
| 4 | socket + HTTP 服务 | 浏览器打开页面,点按钮灯亮 |
| 5 | poll 事件循环 + 信号处理 | Ctrl+C 能干净退出,资源全释放 |
| 6 | valgrind + 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的返回值是什么(不是实际写入长度)
十一、这一关结束时你应该能回答
class Relay表达的核心是什么?C 里用什么替代?- 为什么所有
close函数必须幂等? - C 的「多态」是怎么实现的?
void *ctx对应 C++ 的什么? undefined reference和was not declared分别在哪个阶段报?snprintf返回「本来想写多少」,为什么这个设计反而更安全?poll单线程和多线程各自什么时候更合适?
答不出第 1、3 题,说明还停在「翻译语法」,没到「重新解决同一个问题」。 而这正是 S1 唯一想教的东西。
下一站
S1 收口后,你手上第一次有了一个自己写的、能跑、能交付的 C 程序。
S2 把它推到没有操作系统的地方——那才是思维方式真正断层的地方。
参考
libgpiod、POSIX poll / pthread、snprintf 与结构体对齐语义依据各自手册与 C11 标准;C 语言基础与「指针即内存布局」的讲法参考《程序设计基础(C语言)》与《嗨翻C语言》。代码、四张图、排查清单、排期与过门标准均为原创整理,其中 gw.c 是完整可编译的示例,可直接用于教学。