第 12 章 · 一个指针指所有 LED · 向上转型
配套代码:oop-in-c/code/12-upcasting/
12.1 上一章的代码里藏着一个秘密
本章配套代码相对第 11 章给 led_gpio 加了一个 on_level 字段(第四个参数 true 表示高电平点亮,低电平点亮就传 false),子类构造签名跟着多了一个参数。这个改动 § 12.8.7 会展开,先记住第四个参数是干什么的。
第 11 章末尾你写过这样的调用(已经是 ch12 形态):
struct led_gpio gpio_led;
led_gpio_init(&gpio_led, "ERR", 10, true);
led_on(&gpio_led.base); /* 调过去 */
led_off(&gpio_led.base);
注意这一行 &gpio_led.base。
led_on 的参数类型是 struct led_base *。gpio_led 的类型是 struct led_gpio。你把一个 led_gpio 对象的 base 字段地址,当成 led_base * 传了进去。
C 编译器一句话不说就让你过了。这个你一直在用但没仔细想过的事,今天揭穿。

12.2 内存布局里的不变量
第 11 章演化出了这个布局(slide 上写作 LedGpio / LedBase,本书用 Linux 内核风格的 struct led_gpio / struct led_base,含义一致):
struct led_gpio {
struct led_base base; /* 父类,必须在第一个位置 */
uint8_t pin;
bool on_level;
};
把它在内存里画出来:
+---------+---------+---------+ <- &gpio_led
| ops* | name* | is_on | base 部分
+---------+---------+---------+
| pin | on_lvl | (pad) | gpio 自己的部分
+---------+---------+---------+
base 是子类的第一个字段,位于偏移 0。
C 标准(C99 6.7.2.1 节)保证了一件事:结构体第一个成员的地址,等于结构体本身的地址。也就是说:
&gpio_led == &gpio_led.base (作为指针值)
sizeof(struct led_gpio) > sizeof(struct led_base) (但加大不影响起始地址)
&gpio_led.base 拿到的那个数值,就是 gpio_led 整个对象的起始地址。

这一条不变量,是这一章所有威力的根基。
一个细节:本章 ops 表只剩
on / off两个字段。第 11 章那张表里的toggle已经在 ch11 讲透了多态分发机制,ch12 主线是向上转型本身,ops 表先收缩成两字段让代码更聚焦。第 13 章起按需要再加新字段(set_brightness),跨章演化曲线放在 § 12.8.7 集中说明。
12.3 父类指针是通用句柄
有了这个不变量,就有了一个事实:
一个
struct led_base *指针,拿到&gpio_led.base之后,它实际上握着整个gpio_led对象。
它不是只能看见 base 部分。它握着整个对象。需要的时候(gpio_on 内部要用 pin 字段),可以从 base 反推回去。这一步在第 13 章会展开,本章先承认它能做到。
把这个事实推到极致:
struct led_base *是任何 LED 对象的通用句柄。
led_gpio、led_pwm、led_i2c 三种子类,背后挂的子类对象不同,对外暴露的都是同一个 struct led_base * 类型。应用层只看到这个类型,不知道也不需要知道背后是哪一个子类。
12.4 全局句柄 + 板级初始化
把这个事实变成项目结构。
新建一个头文件 leds.h,里面只声明全局句柄:
/* leds.h */
#include "led_base.h"
extern struct led_base *g_led_error;
extern struct led_base *g_led_status;
extern struct led_base *g_led_network;
int led_board_init(void);
应用层只 include 这一个文件。看到的是三个 struct led_base *,看不到任何子类类型。
这里出现了两个新头文件,分工不一样要立刻分清楚:
led_base.h:父类层公开头,装“什么是 LED“——struct led_base字段集 +struct led_ops操作表 +led_on / led_off父类统一接口。跨项目复用,从 ch11 一路传到 ch15 一字不变。属于驱动框架。leds.h:LED 模块对外暴露的全局句柄,装“这块板子上有哪几盏 LED“——三个extern struct led_base *句柄 +led_board_init()入口。跟着项目走,换块板子(哪怕同样三盏 LED 但接到不同硬件)就要改这一份。属于 BSP(板级支持包)。函数名带
led_前缀是有意为之。真实工程一块板上不止 LED 一个外设,还有 sensor / uart / motor 等等,每个外设各自一份xxx_board_init.c,谁的硬件参数谁负责,别全塞一个文件。应用层只看
leds.h。子类头文件(led_gpio.h/led_pwm.h/led_i2c.h)只在led_board_init.c里 include,用来 sizeof 占栈。这条纪律 § 15.9.1 会展开讲。
具体硬件呢?锁在另一个文件 led_board_init.c:
/* led_board_init.c */
#include "leds.h"
static struct led_gpio s_led_err; /* 文件作用域,外部不可见 */
static struct led_pwm s_led_status;
static struct led_i2c s_led_net;
struct led_base *g_led_error; /* 句柄定义 */
struct led_base *g_led_status;
struct led_base *g_led_network;
int led_board_init(void)
{
int rc;
rc = led_gpio_init(&s_led_err, "ERR", 10, true);
if (rc != 0) return rc;
rc = led_pwm_init (&s_led_status, "STAT", 1, 50);
if (rc != 0) return rc;
rc = led_i2c_init (&s_led_net, "NET", 0, 0x20);
if (rc != 0) return rc;
g_led_error = &s_led_err.base;
g_led_status = &s_led_status.base;
g_led_network = &s_led_net.base;
return 0;
}
三步:实例化子类对象、跑子类构造函数、把 &xxx.base 赋给全局句柄。子类 init 返回 int 错误码,led_board_init 把任何一颗 LED 的初始化失败原样上抛给 main,板级出问题立刻暴露。
最后这三行是这一章的核心。它把 s_led_err 这个 struct led_gpio 对象,“当作” struct led_base * 句柄在用。子类对象当父类指针看,就是向上转型。

led_board_init 在 main 里开机调一次。这一招在嵌入式叫板级初始化(Board Support Package 的核心)。每个项目一份 led_board_init.c,配置不同板子上 LED 接的是 GPIO 还是 PWM 还是 I2C 扩展芯片。同一块板上的 sensor / uart / motor 各自走自己的 sensor_board_init.c / uart_board_init.c,互不串。
12.5 应用层零硬件字样
来看应用层 main.c:
#include "leds.h"
static void alarm_blink(void)
{
led_on(g_led_error);
led_off(g_led_error);
}
static void network_heartbeat(void)
{
led_on(g_led_network);
led_off(g_led_network);
}
打开终端:
grep -n "led_gpio\|led_pwm\|led_i2c" main.c # 0 行
grep -n "gpio_write\|gpio_init" main.c # 0 行
grep -n "pwm_\|i2c_\|0x20" main.c # 0 行
应用层一个硬件字样都没有。它不认识 GPIO、不认识 PWM、不认识 I2C。它只用句柄。

应用层不认识硬件,硬件是谁它都不问。
12.6 换硬件改一行
老板说:报警灯要能调光,从 GPIO 换成 PWM。
打开 led_board_init.c,改一行。
/* 改前 */
static struct led_gpio s_led_err;
led_gpio_init(&s_led_err, "ERR", 10, true);
g_led_error = &s_led_err.base;
/* 改后 */
static struct led_pwm s_led_err;
led_pwm_init(&s_led_err, "ERR", 2, 80);
g_led_error = &s_led_err.base;
实际上是三行(实例化、init、绑定),都在同一个文件里,一处改完。
main.c 呢?打开 grep。alarm_blink、network_heartbeat、所有业务函数:0 行改动。

改 1 行,换一整套硬件。这就是向上转型的工程化威力。应用层和硬件之间隔着一个 struct led_base * 句柄,硬件那头怎么换,句柄这头一律不知道。
12.7 这个东西叫什么
你刚才做的事,软件工程里有个名字。
把派生类(struct led_gpio)的对象,当作父类(struct led_base)来引用、调用、传递,这叫 向上转型(upcasting)。
在 C++ 里这一步是隐式的:
class LedBase { /* ... */ };
class LedGpio : public LedBase { /* ... */ };
LedGpio gpio_led;
LedBase &handle = gpio_led; /* 编译器自动认 */
LedBase *p = &gpio_led; /* 编译器自动认 */
C++ 编译器看见 LedGpio 继承自 LedBase,不需要任何转换语法,就让父类引用 / 指针绑到派生对象上。
C 里你手动写 &gpio_led.base,做的是同一件事:拿到子类内嵌的父类那一段的地址。底层机器码完全一致,都是“把 gpio_led 这个对象的起始地址,按 LedBase * 类型解读“。

12.8 视频里没讲透的几个细节
12.8.1 为什么 base 必须在第一个位置
C99 标准 6.7.2.1 节第 13 段保证:“结构体的第一个成员的地址等于结构体本身的地址”。后面成员的地址不保证(编译器可以为对齐插 padding)。
所以 &gpio_led == &gpio_led.base 这一条只在 base 是第一个成员时成立。如果有一天某个倒霉的同事把 base 移到第二个,下面这种写法就错:
g_led_error = (struct led_base *)&s_led_err; /* 危险,base 必须在第一个位置 */
它把 s_led_err 的起始地址直接当 led_base * 用。base 一旦不在第一个,地址就偏了,运行时崩。
本书一律不用这种强转。书里用 &s_led_err.base,把“取出哪个成员“显式写出来。base 不管在第几个位置,都对。让编译器自己算偏移,你别去碰。
12.8.2 名字 base 是约定,不是关键字
C 没有继承关键字,base 这个字段名是社区约定。Linux 内核里你会看到很多变体:dev(struct device dev)、parent、super。叫什么不重要,第一个字段必须是父类对象这一点是硬约束。
struct usb_device {
int devnum;
/* ... */
struct device dev; /* 父类,但不在第一个位置 */
};
也合法。但访问父类时只能写 &usb->dev,不能强转。Linux 内核里这种安排到处都是,下一章会专门讲怎么应付这种情况。
12.8.3 句柄类型要不要 const
struct led_base *g_led_error; 这一行有两个潜在写法:
/* 方案 A */
struct led_base *g_led_error;
/* 方案 B */
struct led_base * const g_led_error; /* 不让别人重新指向 */
工程上一般写方案 A。真实项目里 led_board_init 之后偶尔需要重定向(比如热插拔、运行时切换备份硬件),加 const 反而碍事。如果你的项目永远不重定向,加 const 没毛病。
12.8.4 编译器拒绝的是什么
试一下这一行:
g_led_error = &s_led_err; /* 类型不匹配 */
&s_led_err 的类型是 struct led_gpio *,g_led_error 的类型是 struct led_base *。这两个类型在 C 里完全独立,编译器报 incompatible pointer types。
C++ 里编译器认识继承关系,会自动把 LedGpio * 收窄成 LedBase *。C 里编译器不认识,你必须主动告诉它“我要的是 base 那一段“,写成 &s_led_err.base。
这一行 .base 的本质,就是把 C++ 编译器藏起来的偏移计算(对 base 来说是 0),手动写出来。看上去多此一举,实际上是 C 比 C++ 透明的地方:偏移多少、转换发生在哪一步,全在你眼皮底下。
12.8.5 这一招在汇编层面零开销
g_led_error = &s_led_err.base; 这一行,&s_led_err.base 在编译期被处理成“加上 base 的偏移“。base 在第 0 个位置,偏移就是 0,编译器一条加法指令都不会生成,机器码就是把 s_led_err 的地址直接搬过去。
如果有一天 base 不在第一个位置,编译器自动把偏移改成对应数值(比如 4),代码逻辑还对。这就是为什么用 &xxx.base 而不用强转:让编译器替你算偏移,永远是对的。
12.8.6 这一招在 Linux 内核里的样子
打开 Linux 内核 drivers/leds/led-class.c,你会看到:
struct led_classdev {
const char *name;
/* ... */
struct device *dev;
/* ... */
};
led_classdev 是 LED 子系统的父类。具体的 LED 驱动(比如 leds-gpio.c 里的 GPIO LED)会嵌入这个 led_classdev:
struct gpio_led_data {
struct led_classdev cdev; /* 父类 */
struct gpio_desc *gpiod;
/* ... */
};
向上转型时写 &gpio_data->cdev。整个 Linux LED 子系统就是这一招的工业级展开。
12.8.7 配套代码相对第 11 章的演化点
差异原则详见前言「配套代码 vs 视频版」。下面是本章具体差异。
打开第 12 章配套代码 oop-in-c/code/12-upcasting/,跟第 11 章 oop-in-c/code/11-polymorphism/ 对一下,会发现两处变化。这些变化在主线叙事里没特别说,这里集中说一下。本章 § 12.1 引的 led_gpio_init(&gpio_led, "ERR", 10, true) 和 § 12.2 画的 struct led_gpio { base; pin; on_level; },都已经是下面这个 ch12 的形态。
struct led_gpio 加了 on_level 字段:
/* ch11: */
struct led_gpio {
struct led_base base;
uint8_t pin;
};
/* ch12: */
struct led_gpio {
struct led_base base;
uint8_t pin;
bool on_level; /* 高电平亮 = true,低电平亮 = false */
};
不是所有 LED 都是高电平亮。GPIO 反向接法(共阳极接法)需要拉低电平才点亮。on_level 把这个硬件极性差异封装在子类里,应用层 led_on(handle) 不用知道具体是高还是低。led_gpio_init 也跟着多了一个 bool on_level 参数,签名从三参变成四参,返回类型仍是 int,跟第 10、11 章一脉相承,所有错误码都向上抛给 led_board_init。
struct led_ops 字段集收缩:
/* ch11: */
typedef int (*led_action_fn)(struct led_base *me);
struct led_ops {
led_action_fn on;
led_action_fn off;
led_action_fn toggle;
};
/* ch12: */
struct led_ops {
int (*on)(struct led_base *me);
int (*off)(struct led_base *me);
};
第 12 章主题是向上转型,应用层只调 led_on / led_off,toggle 在这一章用不上,先收缩成两字段,让代码更聚焦。led_action_fn typedef 也跟着撤掉,改成内联函数指针类型。typedef 一旦字段集不再统一就没意义。第 13 章及以后会按需要再重新加字段。
led_base.h / led_base.c 跟第 10、11 章一脉相承:父类 struct led_base 字段集(ops + name + is_on)+ struct led_ops 字段集 + 通用 led_base_init + 父类统一接口 led_on / led_off 全都集中在这一对文件里。三种子类(GPIO / PWM / I2C)的 init 第一行都调 led_base_init 把对应的 ops 表填进去。这样哪天 base 加了一个公共字段(例如 owner / lock),只改 led_base_init 一个点,三种子类的 init 不用动。父类层公开接口集中在 led_base.h 一份头文件,应用层 #include "led_base.h" 拿到 struct led_ops 字段集 + 两个父类接口声明就够用 – 实际工程里应用层连 led_base.h 都不需要直接 include,只 include 板级聚合头 leds.h,见 § 12.4。
文件组织相对第 11 章的另一处调整:三种子类各拆出独立的 .h / .c。
oop-in-c/code/12-upcasting/pc/
├── led_base.h / led_base.c 父类层公开头:字段集 + ops 表 + 共有 init + 父类统一接口
├── led_gpio.h / led_gpio.c GPIO 子类(独立文件)
├── led_pwm.h / led_pwm.c PWM 子类(独立文件)
├── led_i2c.h / led_i2c.c I2C 子类(独立文件)
├── leds.h / led_board_init.c 板级 BSP(应用层唯一入口:全局 base 句柄 + 板级 init)
└── main.c 应用层(只 #include "leds.h")
每个子类一对 .h / .c 是 Linux 内核和工业项目通用风格 – git blame 定位改动到具体子类、ABI 调整时只动一对文件。子类 .c 里的 gpio_ops / pwm_ops / i2c_ops 表全部 static const 锁在自己 .c 内 – 子类 init 第一行 led_base_init(&me->base, name, &xxx_ops) 就把 ops 表交给 base,外面(包括 led_board_init.c 和应用层)拿不到也用不到。子类 .h 只暴露 struct led_xxx 字段集 + 构造函数 led_xxx_init 声明,第 11 章里也是这种风格。
12.9 你现在的代码在 STM32 上长什么样
应用层 main.c、父类 led_base.c 一字不改。led_board_init.c 里把 GPIO 子类的 pin 参数从 PC 占位换成真实板子的 PIN_NUM,PWM / I2C 不动。platform_pc.c 替换成 platform-mcu/stm32/ 下三个文件 – 每个子类一个:led_gpio.c 装 GPIO 子类实现 + platform_gpio_xxx 真实 HAL 胶水(PIN_NUM('A', 13) 这套编码 _gpio_table 查表拿到 GPIOA);led_pwm.c 装 PWM 子类实现 + TIM 操作;led_i2c.c 装 I2C 子类实现 + HAL_I2C_Master_Transmit:
#include "platform.h"
#include "stm32f4xx_hal.h"
void platform_gpio_init(uint8_t pin, uint8_t mode)
{
GPIO_InitTypeDef cfg = {0};
_enable_port_clock(pin);
cfg.Pin = PIN_MASK(pin);
cfg.Mode = (mode == GPIO_MODE_OUTPUT) ?
GPIO_MODE_OUTPUT_PP : GPIO_MODE_INPUT;
cfg.Pull = GPIO_NOPULL;
cfg.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(PIN_PORT(pin), &cfg);
}
void platform_gpio_write(uint8_t pin, bool value)
{
HAL_GPIO_WritePin(PIN_PORT(pin), PIN_MASK(pin),
value ? GPIO_PIN_SET : GPIO_PIN_RESET);
}
/* ... deinit / read 同样直接调 HAL ... */
完整片段见 oop-in-c/code/12-upcasting/platform-mcu/stm32/(含 led_gpio.c / led_pwm.c / led_i2c.c 三个子类文件 + STM32 版 led_board_init.c)。完整跑通的 STM32 工程见附录 B。
PWM 子类在真实硬件上会用 HAL_TIM_PWM_Start + __HAL_TIM_SET_COMPARE。I2C 子类会用 HAL_I2C_Master_Transmit。
本节展示的是 ch11 起整本书统一的函数式包装:platform_gpio_init / platform_gpio_write / platform_gpio_deinit / platform_gpio_read 四个函数声明在 common/platform.h,PC 上跑 printf 模拟,STM32 上跑真实 HAL。上层一字不改。
12.10 工业代码里的向上转型长什么样
工业控制板项目里,板级初始化是这样:
/* drivers/led/leds.h(节选) */
struct led_base;
extern struct led_base *green_led;
extern struct led_base *red_led;
extern struct led_base *blue_led;
/* board/led_board_init.c(节选) */
static struct led_gpio s_green;
static struct led_gpio s_red;
static struct led_pwm s_blue;
struct led_base *green_led;
struct led_base *red_led;
struct led_base *blue_led;
int led_board_init(void)
{
led_gpio_init(&s_green, "GREEN", PIN_GPIO_GREEN, true);
led_gpio_init(&s_red, "RED", PIN_GPIO_RED, true);
led_pwm_init (&s_blue, "BLUE", PWM_CHAN_BLUE, 0);
green_led = &s_green.base;
red_led = &s_red.base;
blue_led = &s_blue.base;
return 0;
}
应用层任何模块 #include "leds.h" 之后,能拿到 green_led、red_led、blue_led 三个句柄。要点几下指示灯:
led_on(green_led);
led_off(red_led);
应用层代码里 grep led_gpio / grep led_pwm 全部 0 行。换硬件方案、换板子型号,应用层一行不动。
这就是向上转型在真实工业项目里的最终形态。
12.11 跑一遍
cd oop-in-c/code/12-upcasting/pc
make
./demo
输出节选:
=========================================
ch12 - upcasting
one led_base * handle, any subclass
=========================================
[base] "ERR" common init done, ops=0040b1b8
[GPIO] PA.10 init as OUTPUT
[GPIO] PA.10 -> LOW (OFF)
[base] "STAT" common init done, ops=0040b1f8
[base] "NET" common init done, ops=0040b250
--- alarm_blink ---
[GPIO] PA.10 -> HIGH (ON)
[ERR] GPIO Pin10 ON
[GPIO] PA.10 -> LOW (OFF)
[ERR] GPIO Pin10 OFF
--- network_heartbeat ---
[NET] I2C bus0 addr=0x20 reg=0x01
[NET] I2C bus0 addr=0x20 reg=0x00
=========================================
app layer: zero hardware reference
=========================================
开机阶段三行 [base] "X" common init done 是父类 led_base_init 打的,三种子类 init 第一行都调它把 ops 表填到 base,再各自跑硬件层 init([GPIO] PA.10 init as OUTPUT 这一段就是 GPIO 子类 init 末尾把灯先关掉的过程)。
两个业务函数,两种不同硬件,应用层一行 GPIO/PWM/I2C 字样都没写。这就是向上转型工程化之后的样子。
12.12 视频回放
下一章
应用层只见 struct led_base *,挺好。但 gpio_on 这个函数收到的也是 struct led_base *,它要操作 pin,pin 在子类 struct led_gpio 里,不在 base 里。怎么从 base 反推回 gpio?
下一章揭穿。Linux 内核有一个宏,优雅到你想裱起来。