从零搭建自动驾驶中间件(二):共享内存零拷贝通信的工程实践

这是系列第二篇。上一篇聊了"为什么自研",这篇开始聊"怎么设计"------从自动驾驶系统最核心的通信层开始。

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_sizedata_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 模型。我们选择环形缓冲区的原因:

  1. 天然支持多消费者:每个读者维护自己的 read_index,互不干扰
  2. 天然支持 READ_LATEST:读者可以直接跳到 write_index
  3. 无内存分配:固定大小的环形队列,启动时分配,运行时零分配
  4. 缓存友好:连续内存布局,CPU cache 命中率高

代价是:队列深度固定,溢出时旧数据被覆盖。但在自动驾驶场景中,这反而是优势------过期的传感器数据没有价值,应该被丢弃。


3. 零拷贝的实现细节

3.1 写入端

cpp 复制代码
// Module 内部
SensorData data;
data.timestamp = GetTimestamp();
data.point_count = 150000;
// ... 填充数据 ...

Write("lidar_points", data);  // 一次 memcpy 写入共享内存

写入过程:

  1. 在环形缓冲区中找到 write_index 指向的 slot
  2. memcpy 数据到 slot(只有这一次拷贝,无法避免)
  3. 递增 write_index 和 frameid

3.2 读取端(零拷贝)

cpp 复制代码
// Module 内部
const SensorData* data = nullptr;
Get("lidar_points", data);  // 返回共享内存指针,零拷贝!

// data 直接指向共享内存中的数据,无需 memcpy
ProcessPointCloud(data);

读取过程:

  1. 在环形缓冲区中根据 read_index 找到 slot
  2. 直接返回指针,不做任何拷贝
  3. 读者用完后,指针自然失效(被新的写入覆盖)

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 跨进程共享内存

不同进程间的通信仍然基于共享内存,但需要:

  1. 共享内存文件 :Linux 上通过 shm_open 创建,不同进程通过相同的 name 访问

  2. 数据分组:同一组数据放在同一个共享内存文件中,减少文件数量

    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"]
    }
  ]
}

inputoutput 声明了模块的数据依赖,框架会自动创建对应的 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. 踩坑总结

  1. 共享内存权限shm_open 默认权限是 0600,多进程需要 chmod 或设置 umask
  2. 信号丢失:标准信号(SIGUSR1 等)不可靠,必须用实时信号(SIGRTMIN+)
  3. 缓存一致性 :ARM 平台上写入后需要内存屏障(dmb),否则读者可能读到旧数据
  4. 共享内存泄漏:进程崩溃后共享内存不会自动清理,需要启动时检查并回收
  5. 零拷贝的生命周期:必须在 Step 内消费完,跨步持有是 undefined behavior

10. 小结

共享内存零拷贝通信是自动驾驶中间件的基石。它的设计看似简单------不就是读写内存吗?但工程实现中需要处理跨进程、多消费者、帧号追踪、信号通知等众多细节。

HyperFlow 的选择是:环形缓冲区 + POSIX 信号 + 静态配置,用约束换取性能。这不是唯一的方案,但它是我们在嵌入式自动驾驶场景下验证过的最优解。

下一篇,我们将聊调度层------有了数据通信之后,如何高效地调度模块的执行。


下期预告:《从零搭建自动驾驶中间件(三):事件驱动与协程调度的工程实践》

相关推荐
moonsims2 小时前
端侧YOLO + 端侧CLIP + 云端CLIP(AI Mission Cloud):云-边-端协同语义感知与任务系统架构
人工智能
古希腊掌管代码的神THU2 小时前
【清华代码熊】DeepSeek V4多模态技术解析:以视觉基元思考
人工智能·深度学习·自然语言处理
l1t2 小时前
DeepSeek辅助编写埃拉托斯特尼筛法和Atkin筛法求质数程序比较
开发语言·人工智能·python
Q渡劫2 小时前
6 种 Agent 类型的完整可运行代码
人工智能
纪伊路上盛名在2 小时前
机器学习中常见的距离度量函数 Distance metrics
人工智能·算法·机器学习·数据分析·统计
美团技术团队2 小时前
用Agent评测思路管理AI Coding —— 31万行代码AI重构的实践
人工智能
OpenCSG2 小时前
AI 不再只是聊天机器人:企业为什么开始构建自己的 Agent 系统?
人工智能·机器人
经济元宇宙2 小时前
哪款工业仿真软件上手简单?企业常用款推荐
人工智能·算法
大侠区块链3 小时前
我面试了上百个想进 AI 公司的人,发现他们都搞错了一件事--深度精读 | 对话 Anthropic Claude Code 产品负责人 Cat Wu
人工智能·面试·职场和发展