这是系列第二篇。上一篇聊了"为什么自研",这篇开始聊"怎么设计"------从自动驾驶系统最核心的通信层开始。
1. 为什么必须上共享内存?
自动驾驶系统中,模块间的数据流转是这样的:
激光雷达 → 点云预处理 → 3D检测 → 目标跟踪 → 轨迹预测 → 行为规划 → 运动控制
10Hz 10Hz 10Hz 10Hz 10Hz 10Hz 100Hz
一帧点云大约 100KB~2MB,一帧图像 5~20MB。如果每帧数据在模块间传递都要拷贝,那光拷贝开销就很可怕:
一帧点云 1MB × 消费者数量 3 × 频率 10Hz = 30MB/s 的内存拷贝带宽
一帧图像 10MB × 消费者数量 2 × 频率 30Hz = 600MB/s 的内存拷贝带宽
这些拷贝不仅吃 CPU、吃内存带宽,还会带来不可控的延迟抖动。
共享内存的核心思想很简单:大家访问同一块内存,谁也不拷贝。 但工程实现上有很多细节需要处理。
2. 核心数据结构:DataQueue 环形缓冲区
2.1 设计目标
| 目标 | 说明 |
|---|---|
| 零拷贝读取 | 返回共享内存指针,不拷贝数据 |
| 一写多读 | 一个写者,多个读者独立消费 |
| 帧号追踪 | 每次写入自动递增 frameid |
| 两种读取模式 | READ_LATEST(读最新)和 READ_NEXT(顺序读) |
| 静态配置 | 数据大小和队列深度在配置中指定,运行时无动态分配 |
2.2 环形缓冲区结构
DataQueue (shm_queue)
写入端 (Writer) 读取端 (Reader)
│ │
│ write_index │ read_index
│ │ │ │
▼ ▼ ▼ ▼
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ f0 │ f1 │ f2 │ f3 │ f4 │ f5 │ f6 │ f7 │ ← data_count = 8
└────┴────┴────┴────┴────┴────┴────┴────┘
↑ ↑
│ │
frameid=1 frameid=8
每个 slot 大小 = data_size (配置指定)
队列总大小 = data_size × data_count
关键设计:
data_size和data_count在 JSON 配置中静态指定- 共享内存在启动时一次性
mmap,运行时无malloc - 写入时 frameid 自动递增,读者通过 frameid 判断是否有新数据
2.3 两种读取模式
READ_LATEST(读最新):
适用于下游算法模块------永远只关心最新数据,跳过旧数据。
Writer 写入 frameid=5 → Reader 读取 → 直接返回 frameid=5 的数据
(即使 frameid=3,4 没读过也跳过)
READ_NEXT(顺序读):
适用于录制模块------需要按顺序消费每一条数据。
Writer 写入 frameid=3,4,5 → Reader 依次返回 3, 4, 5...
2.4 为什么用环形而不是 Pub/Sub?
很多框架(ROS2、CyberRT)使用 Pub/Sub 模型。我们选择环形缓冲区的原因:
- 天然支持多消费者:每个读者维护自己的 read_index,互不干扰
- 天然支持 READ_LATEST:读者可以直接跳到 write_index
- 无内存分配:固定大小的环形队列,启动时分配,运行时零分配
- 缓存友好:连续内存布局,CPU cache 命中率高
代价是:队列深度固定,溢出时旧数据被覆盖。但在自动驾驶场景中,这反而是优势------过期的传感器数据没有价值,应该被丢弃。
3. 零拷贝的实现细节
3.1 写入端
cpp
// Module 内部
SensorData data;
data.timestamp = GetTimestamp();
data.point_count = 150000;
// ... 填充数据 ...
Write("lidar_points", data); // 一次 memcpy 写入共享内存
写入过程:
- 在环形缓冲区中找到 write_index 指向的 slot
memcpy数据到 slot(只有这一次拷贝,无法避免)- 递增 write_index 和 frameid
3.2 读取端(零拷贝)
cpp
// Module 内部
const SensorData* data = nullptr;
Get("lidar_points", data); // 返回共享内存指针,零拷贝!
// data 直接指向共享内存中的数据,无需 memcpy
ProcessPointCloud(data);
读取过程:
- 在环形缓冲区中根据 read_index 找到 slot
- 直接返回指针,不做任何拷贝
- 读者用完后,指针自然失效(被新的写入覆盖)
3.3 零拷贝的陷阱
陷阱 1:异步访问
cpp
const SensorData* data = nullptr;
Get("lidar_points", data);
// ❌ 危险!如果此时 Writer 写入新数据,data 指向的内存可能被覆盖
std::thread([data]() {
ProcessData(data); // data 可能已经失效!
}).detach();
解法:零拷贝读取的数据必须在当前 Step 内消费完毕,不能跨步持有指针。如果需要跨步使用,必须拷贝。
陷阱 2:多写者冲突
如果多个写者同时写入同一个 DataQueue,数据会交错。解决方案:
json
{
"name": "shared_data",
"wlock": true, // ← 开启写锁
"data_size": 1024,
"data_count": 8
}
wlock=true 时,写入会加自旋锁,保证原子性。但大多数场景下一个 DataQueue 只有一个写者,不需要加锁。
4. 跨进程通信
自动驾驶系统出于安全隔离考虑,不同功能域通常运行在不同进程:
Process: perception Process: planning Process: control
├─ LidarModule ├─ PredictionModule ├─ ControlModule
├─ CameraModule ├─ PlanningModule └─ DiagModule
└─ FusionModule └─ StateMachineModule
4.1 跨进程共享内存
不同进程间的通信仍然基于共享内存,但需要:
-
共享内存文件 :Linux 上通过
shm_open创建,不同进程通过相同的 name 访问 -
数据分组:同一组数据放在同一个共享内存文件中,减少文件数量
DataGroup: "perception_data"
├── DataQueue: "lidar_points" (shm_file: /dev/shm/hf_perception)
├── DataQueue: "camera_front"
└── DataQueue: "fusion_result"DataGroup: "planning_data"
├── DataQueue: "trajectory" (shm_file: /dev/shm/hf_planning)
└── DataQueue: "sys.state"
4.2 跨进程通知:POSIX 信号
数据写入共享内存后,需要通知下游进程"有新数据了"。HyperFlow 使用 POSIX 实时信号:
Process A (Writer) Process B (Reader)
│ │
│ Write("fusion_result", data) │
│ Notify("fusion_ready") │
│ │ │
│ ▼ │
│ NotifyTrigger │
│ │ Signal(pid_B, data) │
│ └────────────────────────────►│
│ │ TriggerManager
│ │ │
│ │ ▼
│ │ ListenTrigger.WakeUp()
│ │ │
│ │ ▼
│ │ Module.Wakeup()
│ │ │
│ │ ▼
│ │ Step() → Read("fusion_result")
为什么用信号而不是条件变量?
- 条件变量只在进程内有效
- 信号是内核级机制,天然跨进程
- 实时信号(SIGRTMIN~SIGRTMAX)可以排队,不会丢失
信号的局限:信号只能携带 int32 数据。我们用它传递 TriggerID,接收端通过共享内存中的 Trigger 表查找具体信息。
4.3 TriggerManager 的跨进程设计
共享内存布局:
┌──────────────────────────────────────────────┐
│ TriggerHeader │
│ trigger_count: N │
│ process_pids: [pid_A, pid_B, ...] │
├──────────────────────────────────────────────┤
│ TriggerEntry[0] { name: "fusion_ready", │
│ type: NOTIFY, │
│ count: 42, │
│ pid_count: 2 } │
│ TriggerEntry[1] { name: "fusion_ready", │
│ type: LISTEN, │
│ listeners: [Module2] } │
│ ... │
└──────────────────────────────────────────────┘
所有进程通过共享内存访问同一张 Trigger 表,实现跨进程的触发器注册和查询。
5. Slice:零拷贝数据切片
在 DataQueue 之上,HyperFlow 还提供了 Slice 数据结构,用于更灵活的零拷贝操作。
5.1 设计思路
Slice 的核心思想是引用计数的字节序列:
Slice 内部布局(小数据优化):
┌──────────────────────────────────────────┐
│ 如果数据 ≤ 24 字节: │
│ [inline data] [size] │ ← 不分配堆内存
│ │
│ 如果数据 > 24 字节: │
│ [refcount_ptr] [data_ptr] [size] │ ← 引用计数管理
└──────────────────────────────────────────┘
小数据优化(Small Buffer Optimization):
自动驾驶中很多控制指令只有几十字节(如方向盘角度、速度指令),不值得 malloc。Slice 对小数据直接内联存储,避免堆分配。
5.2 Slice 的操作
cpp
// 子切片(零拷贝截取)
auto sub = slice.Sub(10, 20); // 截取 offset=10, length=20 的子切片
// 合并
auto merged = Slice::Merge(slice1, slice2); // 逻辑合并,不一定物理拷贝
// 模板包装(直接映射结构体到内存)
SliceWrapper<SensorData> wrapper(slice);
wrapper->timestamp = GetTimestamp(); // 直接写入 slice 内存
6. 配置示例
6.1 数据配置
json
{
"datas": [
{
"name": "lidar_points",
"wlock": false,
"data_size": 2097152, // 2MB
"data_count": 8 // 8 帧缓冲
},
{
"name": "camera_front",
"wlock": false,
"data_size": 20971520, // 20MB
"data_count": 4 // 4 帧缓冲
},
{
"name": "control_cmd",
"wlock": false,
"data_size": 64, // 64 字节
"data_count": 16 // 16 帧缓冲(高频控制需要更多缓冲)
}
]
}
data_count 的选择建议:
| 场景 | 建议值 | 原因 |
|---|---|---|
| 传感器数据 | 4~8 | 防止慢消费者丢帧 |
| 高频控制 | 16~32 | 控制指令频率高,需要更多缓冲 |
| 录制数据 | 8~16 | 录制模块需要顺序读取,不能丢帧 |
6.2 模块配置
json
{
"modules": [
{
"name": "lidar_driver",
"type": "LidarDriver",
"interval": 100,
"output": ["lidar_points"],
"notify": ["lidar_data_ready"]
},
{
"name": "perception",
"type": "PerceptionModule",
"listen": "lidar_driver.lidar_data_ready",
"input": ["lidar_points"],
"output": ["fusion_result"],
"notify": ["fusion_ready"]
}
]
}
input 和 output 声明了模块的数据依赖,框架会自动创建对应的 DataQueue 并绑定读写权限。
7. 性能优化技巧
7.1 避免共享内存碎片
共享内存在启动时一次性分配,不存在碎片问题。但要注意 data_size 的对齐:
c
// ❌ 不对齐
typedef struct {
uint8_t flag;
uint64_t timestamp; // 可能不在 8 字节边界上
} BadData;
// ✅ 对齐
typedef struct {
uint64_t timestamp;
uint8_t flag;
uint8_t _reserved[7]; // 填充到 8 字节对齐
} GoodData;
7.2 DataGroup 减少共享内存文件数
每个 DataGroup 对应一个共享内存文件。将相关的数据放在同一个 Group 中:
json
{
"datas": [
{
"name": "perception_group",
"file_name": "hf_perception",
"datas": [
{ "name": "lidar_points", "data_size": 2097152, "data_count": 8 },
{ "name": "fusion_result", "data_size": 4096, "data_count": 8 }
]
}
]
}
7.3 大数据场景下的帧队列深度
传感器频率 × 最大处理延迟 = 最小队列深度
例如:100Hz 控制指令 × 50ms 最大延迟 = 5 帧
加上安全余量 → data_count = 8~16
8. 与 ROS2/CyberRT 通信层的对比
| 维度 | HyperFlow | CyberRT | ROS2 |
|---|---|---|---|
| 进程内通信 | 共享内存指针,零拷贝 | Intra-process 回调 | DDS 层(需序列化) |
| 跨进程通信 | 共享内存 + POSIX 信号 | SHM Transport | DDS SHM |
| 序列化 | 无 | Protobuf | CDR |
| 多消费者 | 环形队列天然支持 | Channel 支持 | Topic 支持 |
| 零拷贝读取 | Get<T>() 返回指针 |
reader->Read() 拷贝 |
需 loaned message |
| 帧号追踪 | 内建 frameid | message 序列号 | DDS sequence number |
| 配置方式 | JSON 静态配置 | DAG + Protobuf | Launch + Params |
HyperFlow 的核心差异:完全放弃了序列化,用"同平台、同编译器"的约束换取了极致的通信性能。
9. 踩坑总结
- 共享内存权限 :
shm_open默认权限是 0600,多进程需要chmod或设置umask - 信号丢失:标准信号(SIGUSR1 等)不可靠,必须用实时信号(SIGRTMIN+)
- 缓存一致性 :ARM 平台上写入后需要内存屏障(
dmb),否则读者可能读到旧数据 - 共享内存泄漏:进程崩溃后共享内存不会自动清理,需要启动时检查并回收
- 零拷贝的生命周期:必须在 Step 内消费完,跨步持有是 undefined behavior
10. 小结
共享内存零拷贝通信是自动驾驶中间件的基石。它的设计看似简单------不就是读写内存吗?但工程实现中需要处理跨进程、多消费者、帧号追踪、信号通知等众多细节。
HyperFlow 的选择是:环形缓冲区 + POSIX 信号 + 静态配置,用约束换取性能。这不是唯一的方案,但它是我们在嵌入式自动驾驶场景下验证过的最优解。
下一篇,我们将聊调度层------有了数据通信之后,如何高效地调度模块的执行。
下期预告:《从零搭建自动驾驶中间件(三):事件驱动与协程调度的工程实践》