内存泄漏问题

内存泄漏的工具

sudo apt-get install valgrind

valgrind --leak-check=yes ./GODService /home/Downloads/0826pm/tt7

内存泄漏原理、修复原理与数据采集方法


一、内存泄漏的详细原理

1.1 什么是内存泄漏

内存泄漏的本质是:程序向操作系统申请了内存(堆分配),但在不再需要时没有归还,导致这块内存既无法被程序复用,也无法被操作系统回收。随着运行时间累积,进程占用的物理内存(RSS)持续增长,最终触发 OOM(Out-Of-Memory)被系统杀死。

在嵌入式 Linux(ARM Cortex A55,通常 512MB~2GB 内存)上,这个问题尤为致命——你的车机挂机半小时就崩溃,正是因为多个服务同时泄漏,系统内存耗尽。


1.2 五个泄漏点的底层原理

泄漏点 1:裸指针无拷贝控制 → double-free / 泄漏

原理:C++ 中用 new 分配的对象,必须用 delete 释放。如果类持有裸指针成员,但没有正确实现"拷贝构造函数"和"拷贝赋值运算符"(即违反 Rule of Three/Five),会出现两种灾难:

场景 A(double-free):
  对象 obj1 持有指针 p → new 了对象 X
  对象 obj2 = obj1(浅拷贝)→ obj2.p 也指向 X
  obj1 析构 → delete p → X 被释放
  obj2 析构 → delete p → 同一块内存被第二次释放 → 堆损坏 / 崩溃

场景 B(泄漏):
  对象 obj1 持有指针 p → new 了对象 X
  obj1 = obj2(赋值)→ p 被覆盖指向 Y,但原来的 X 没有 delete → X 永远泄漏

你的 StixelWorld 类中:

FreeSpaceDoubao* fsd;                          // 裸指针
HeightSegmentationDoubao* heightsegmentdb;     // 裸指针
// 构造函数 new,析构函数 delete,但没有禁用拷贝

虽然当前代码中 StixelWorldshared_ptr 包裹(StixelDetect.hppstd::shared_ptr<StixelWorld> stixelworld_),暂时不会发生拷贝,但这是一颗定时炸弹——任何后续维护者如果不小心按值传递或赋值,就会触发 double-free。更关键的是,裸指针在异常发生时(如构造函数中第二个 new 抛出异常),第一个已分配的对象无法被自动释放。


泄漏点 2:每帧创建大临时对象 → 内存碎片

原理:这不是严格意义上的"泄漏"(内存最终会被释放),但效果与泄漏相同——进程 RSS 持续增长且不回落。这是嵌入式平台上最常见、也最容易被忽视的问题。

时间线(每帧 100ms,即每秒 10 帧):

t=0ms:   cv::Mat1f columns(128, 360)  → 申请 184,320 字节(184KB)
t=50ms:  columns 使用完毕,函数返回    → 释放 184KB
t=100ms: 下一帧,再次申请 184KB        → 但上一块可能还没被"归还"给 OS
t=150ms: 再次释放...
...

为什么会碎片? glibc 的 malloc 使用 ptmalloc 分配器,它的工作机制是:

  1. 小块内存(< 128KB)通过 brk() 从堆顶分配,释放后不会立即归还操作系统,而是留在进程的 free list 中供下次复用

  2. 但如果多次申请/释放的大小不一致,或者中间有其他分配"插桩",free list 中会留下大量无法合并的空洞

  3. 你的 columns 是 184KB(>128KB),走 mmap 分配路径——mmap 的内存释放后理论上会归还 OS,但频繁 mmap/munmap 会导致 虚拟地址空间碎片缺页中断开销

更严重的是,你的 compute() 函数中同时还有:

  • std::vector<float> roadDisp(vmax) — 每帧创建

  • std::vector<int> lowerPathDb(columns.rows) — 每帧创建

  • std::vector<int> upperPath_Doubao(columns.rows) — 每帧创建

  • PCL 点云 cloud.makeShared() — 每帧创建两次

这些分配/释放交织在一起,分配器很难高效回收,最终表现为 RSS 只增不减


泄漏点 3:失败路径忘记 delete → 确定性泄漏

原理:这是最经典的 C++ 内存泄漏模式——new 之后,在某个条件分支中没有 delete 就跳出了作用域。

goyu_image_data* tmp = new goyu_image_data;   // 申请内存(含多路图片缓冲,约数MB)

if (isGood && (num_image == 2 || num_image == 4)) {
    image_paths.push_back(tmp);   // ✅ 成功路径:加入 vector,后续会被使用
}
// ❌ 失败路径:isGood=false 或 num_image 不匹配
//    tmp 既没有加入 vector,也没有 delete
//    函数结束后 tmp 指针销毁,但指向的内存永远丢失

每个 goyu_image_data 对象内部包含 CameraForRearPictureData,其中有 front_left_picfront_right_picrear_left_picrear_right_pic 四路图片数据,每路都有 picture_data 缓冲。单个对象可能占用数 MB。

虽然这个函数只在 AMD 平台的离线回放模式#if defined(AMD))下调用,但如果回放目录中有损坏的图片文件(isGood=false),每遇到一张坏图就泄漏数 MB,回放几千张图后泄漏量非常可观。


泄漏点 4:重复 makeShared() → 双倍内存 + 碎片

原理:PCL 的 PointCloud::makeShared() 会在堆上创建一个新的 PointCloud 对象并返回 shared_ptr

// 原代码
tree->setInputCloud(cloud.makeShared());           // 创建 PointCloud A(引用计数=1)
euclidean_cluster->setInputCloud(cloud.makeShared()); // 创建 PointCloud B(引用计数=1)
  • cloud 是栈上的 pcl::PointCloud<pcl::PointXYZ> 对象

  • 第一次 makeShared():把 cloud 的数据深拷贝到堆上的 PointCloud A,A 的引用计数为 1,被 tree 持有

  • 第二次 makeShared():又把 cloud 的数据深拷贝到堆上的 PointCloud B,B 的引用计数为 1,被 euclidean_cluster 持有

  • 函数结束时,A 和 B 分别被 tree 和 euclidean_cluster 释放(因为它们是成员变量,持有的是上一帧的点云,setInputCloud 会替换旧的)

问题在于:两个点云内容完全相同,却分配了两份内存。如果点云有 5000 个点(每个点 16 字节 = 80KB),每帧就多浪费 80KB。同时,两次独立的堆分配增加了内存碎片的概率。


泄漏点 5:vector capacity 不释放 → 内存持续占用

原理std::vectorclear() 方法只将 size 置为 0,不会释放 capacity(已分配的内存)

某一帧场景特别复杂(路口、多车多人):
  stixels 从 0 增长到 3000 个 → vector 自动扩容,capacity 变为 4096
  占用内存:4096 × sizeof(Stixel) ≈ 4096 × 24 = 98KB

后续帧场景简单(直道、无障碍物):
  stixels.clear() → size=0,但 capacity 仍为 4096
  这 98KB 内存被持续占用,不会归还操作系统

如果 world_pixels(三维 vector)某一帧有 2000 个 stixel:
  外层 vector capacity=2048
  每个内层 vector<std::vector<float>> 也有自己的 capacity
  最内层 vector<float>(3个元素)每个都有独立的堆分配 + allocator overhead
  总占用可能达到 2048 × (24 + 24 + 12) ≈ 123KB + 大量堆碎片

std::vector::shrink_to_fit() 理论上可以释放多余 capacity,但 C++ 标准规定这是一个非强制请求(“non-binding request”),实现可以忽略。因此最可靠的方式是 swap 技巧

std::vector<T>().swap(vec);  // 创建临时空 vector,与 vec 交换,临时 vector 析构时释放原内存

你的代码中 world_pixelscluster_stixelspublic 成员,在 detect() 中被 clear(),在 get_visual() 中被重新填充。如果某一帧 get_visual() 没有被调用(visual_common_result=false),它们就保持空但 capacity 很大的状态,持续占用内存。


二、修复的原理

2.1 RAII 与 unique_ptr —— 用对象生命周期管理内存

核心思想资源获取即初始化(RAII)——将资源(内存)的生命周期绑定到对象的生命周期上。对象构造时获取资源,对象析构时自动释放资源,无论正常返回还是异常抛出。

// 修复前:手动管理,容易出错
FreeSpaceDoubao* fsd = new FreeSpaceDoubao();  // 获取
// ... 中间可能抛异常 ...
delete fsd;  // 释放(如果抛异常就到不了这里)

// 修复后:unique_ptr 自动管理
std::unique_ptr<FreeSpaceDoubao> fsd = std::make_unique<FreeSpaceDoubao>();
// fsd 离开作用域时自动 delete,无论是否抛异常
// 不能拷贝(编译报错),只能移动 → 从根源杜绝 double-free

为什么 unique_ptr 零开销? 它在编译期内联了 delete 调用,生成的汇编代码与手写 delete 完全相同,没有任何运行时开销。ARM 平台上同样高效。

禁用拷贝构造的原理= delete 告诉编译器"这个函数不存在",如果有人尝试拷贝,编译阶段就报错,而不是运行时崩溃。这是编译期安全优于运行时调试的典型实践。


2.2 预分配复用 —— 消除频繁分配/释放

核心思想把"每帧申请-使用-释放"的模式,改为"启动时申请一次-每帧复用-程序结束释放"

// 修复前:每帧 184KB 的分配/释放
void compute(...) {
    cv::Mat1f columns(umax, vmax);  // 每帧 new 184KB
    // ... 使用 ...
}  // 每帧 delete 184KB

// 修复后:成员变量预分配,每帧复用
class StixelWorld {
    cv::Mat1f columns_;  // 成员变量,生命周期 = 对象生命周期
};
void set_width_height_disparity(...) {
    columns_.create(umax, vmax);  // 只在尺寸变化时分配一次
}
void compute(...) {
    cv::Mat1f& columns = columns_;  // 引用复用,零分配
    // ... 使用(直接覆盖写入,不需要先清空)...
}

为什么引用 cv::Mat1f& columns = columns_ 是安全的?

  • columns_ 是成员变量,在 compute() 调用期间不会被销毁

  • 引用只是一个别名,不涉及拷贝或引用计数操作

  • columns 的写入就是对 columns_ 的写入,下一帧直接覆盖即可

  • 添加尺寸检查 if (columns_.rows != umax || columns_.cols != vmax) 是为了防御性编程——如果输入分辨率变化,自动重新分配

内存碎片消除的原理:分配器的压力从"每帧 N 次分配/释放"降低到"启动时 1 次分配"。free list 中不会产生大量空洞,mmap 区域也不会频繁伸缩,进程 RSS 稳定在一个固定值。


2.3 失败路径补 delete —— 确保所有路径都释放

核心思想每一个 new 都必须在所有可能的退出路径上有对应的 ****delete

// 修复前:只有成功路径释放
if (条件) {
    vec.push_back(tmp);  // 所有权转移给 vec
}
// 失败路径:tmp 泄漏

// 修复后:所有路径都释放
if (条件) {
    vec.push_back(tmp);  // 成功:所有权转移
} else {
    delete tmp;          // 失败:手动释放
}

更优的做法(未来改进方向):使用 std::unique_ptr 管理 tmp,成功时 release() 转移所有权,失败时自动释放:

auto tmp = std::make_unique<goyu_image_data>();
if (条件) {
    vec.push_back(tmp.release());  // release() 放弃所有权,返回裸指针
}
// 失败时 tmp 自动 delete,不需要写 else

但本次修复保持了最小改动原则,只补了 else { delete tmp; },不改变数据结构的所有权语义。


2.4 单次 makeShared() —— 消除冗余分配

核心思想同一份数据只创建一个共享实例,多个消费者共享所有权

// 修复前:两次深拷贝
tree->setInputCloud(cloud.makeShared());           // PointCloud A
euclidean_cluster->setInputCloud(cloud.makeShared()); // PointCloud B(内容同A)

// 修复后:一次创建,共享引用
auto cloud_ptr = cloud.makeShared();  // PointCloud A,引用计数=1
tree->setInputCloud(cloud_ptr);           // 引用计数=2
euclidean_cluster->setInputCloud(cloud_ptr); // 引用计数=3
// 函数结束:cloud_ptr 销毁 → 引用计数=2
// 下一帧 setInputCloud 替换旧的 → tree 和 euclidean_cluster 各自释放旧引用
// 当引用计数归零时,PointCloud A 被释放

shared_ptr** 引用计数的原理**:shared_ptr 内部有一个控制块(control block),存储引用计数(原子变量)和删除器。每次拷贝 shared_ptr,引用计数 +1(原子递增,线程安全);每次 shared_ptr 销毁或被重置,引用计数 -1;当计数归零时,调用删除器释放对象。

为什么这次修复既省内存又减碎片? 减少了一次 80KB 级的堆分配和一次对应的释放,降低了分配器的压力。


2.5 定期 swap 释放 capacity —— 回收异常大帧的内存

核心思想在"性能"和"内存占用"之间取平衡——不每帧释放(避免重新分配开销),但定期检查并释放异常大的 capacity

static int counter = 0;
if (++counter >= 100) {       // 每 100 帧(约 10 秒)检查一次
    counter = 0;
    if (world_pixels.capacity() > 200) {
        // swap 技巧:临时空 vector 与 vec 交换
        // 临时 vector 析构时释放原内存(强制,不受 shrink_to_fit 非强制限制)
        std::vector<std::vector<std::vector<float>>>().swap(world_pixels);
    }
    // ... 其他 vector 同理
}

为什么是 100 帧?

  • 你的帧率约 10fps,100 帧 = 10 秒

  • 每 10 秒做一次 capacity 检查和可能的 swap,性能开销可忽略(< 0.1ms)

  • 如果某一帧场景异常复杂导致 capacity 暴涨,最多 10 秒后就会被回收

  • 不会每帧 swap——那样会导致每帧重新分配,反而降低性能

为什么阈值是 200/2000/5000?

  • world_pixelscluster_stixels:正常场景通常 < 100 个 stixel/cluster,阈值 200 意味着只有超过正常两倍时才释放

  • stixels_global:正常 < 1000 个点,阈值 2000

  • stixels:正常 < 2000 个,阈值 5000

  • output_arr:正常 < 100 个障碍物,阈值 500

  • 阈值设置的原则:只释放异常大的 capacity,不干预正常范围的预分配(正常范围的预分配对性能有好处)

swap 技巧为什么比 shrink_to_fit() 可靠?

// shrink_to_fit:非强制,实现可能忽略
vec.shrink_to_fit();  // capacity 可能不变

// swap:强制释放
std::vector<T>().swap(vec);  // capacity 一定变为 0
// 原理:临时空 vector 的 capacity=0,与 vec 交换后
//       vec 的 capacity=0,临时 vector 持有原内存
//       临时 vector 离开作用域析构,原内存被释放

三、两个 Excel 监控文件的采集方法

这两个文件是你在车机板端采集的进程内存监控数据。以下是在 嵌入式 Linux 板端(ARM Cortex A55) 上生成这类 Excel 的完整步骤,你可以用同样的方法验证修复效果。

3.1 采集原理

Linux 内核通过 /proc 文件系统暴露每个进程的内存信息。最常用的是:

# 查看单个进程的内存状态
cat /proc/<PID>/status
# 关键字段:
# VmRSS:   实际物理内存(Resident Set Size),单位 KB —— 这是你最关心的
# VmSize:  虚拟内存大小
# VmPeak:  虚拟内存峰值

3.2 采集步骤

步骤 1:启动目标服务,获取 PID
# 启动 GODService(或你的启动脚本)
./GODService &

# 获取 PID
GOD_PID=$(pgrep -f GODService)
echo "GODService PID: $GOD_PID"
步骤 2:编写采集脚本

在板端创建 mem_monitor.sh

#!/bin/bash
# mem_monitor.sh - 定时采集指定进程的内存并输出 CSV

PID=$1              # 目标进程 PID
INTERVAL=${2:-2}    # 采样间隔(秒),默认 2 秒
OUTPUT=${3:-mem.csv}

# 写 CSV 表头
echo "时间戳,轮次,VmRSS(KB),VmSize(KB),VmPeak(KB)" > "$OUTPUT"

count=0
while true; do
    # 检查进程是否还活着
    if ! kill -0 "$PID" 2>/dev/null; then
        echo "进程 $PID 已退出,采集结束"
        break
    fi
    
    # 从 /proc/<PID>/status 提取内存字段
    rss=$(grep VmRSS /proc/$PID/status | awk '{print $2}')
    size=$(grep VmSize /proc/$PID/status | awk '{print $2}')
    peak=$(grep VmPeak /proc/$PID/status | awk '{print $2}')
    ts=$(date '+%Y-%m-%d %H:%M:%S')
    
    count=$((count + 1))
    echo "$ts,$count,$rss,$size,$peak" >> "$OUTPUT"
    
    sleep "$INTERVAL"
done
步骤 3:运行采集
# 给脚本执行权限
chmod +x mem_monitor.sh

# 采集 GODService,每 2 秒一次,输出到 god_mem.csv
./mem_monitor.sh $GOD_PID 2 god_mem.csv
步骤 4:多进程同时采集(你的 Excel 中有多个服务)

如果要同时监控 PerceptionFusionServiceTimSyncServiceAppServiceGODService 等多个服务,写一个批量采集脚本:

#!/bin/bash
# multi_mem_monitor.sh - 同时监控多个进程

INTERVAL=2
OUTPUT=multi_mem.csv

# 服务名列表
SERVICES=("PerceptionFusionService" "TimSyncService" "AppService" "GODService" "ParkingInservice" "LFusionService")

# 构建表头
header="时间戳,轮次"
for svc in "${SERVICES[@]}"; do
    header="$header,${svc}_VmRSS(KB)"
done
echo "$header" > "$OUTPUT"

count=0
while true; do
    count=$((count + 1))
    ts=$(date '+%Y-%m-%d %H:%M:%S')
    line="$ts,$count"
    
    for svc in "${SERVICES[@]}"; do
        pid=$(pgrep -f "$svc" | head -1)
        if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
            rss=$(grep VmRSS /proc/$pid/status | awk '{print $2}')
        else
            rss="N/A"
        fi
        line="$line,$rss"
    done
    
    echo "$line" >> "$OUTPUT"
    sleep "$INTERVAL"
done
步骤 5:触发测试场景
  • 空闲静置测试(对应《车子空闲静置内存监控情况》):
# 启动所有服务后,不做任何操作,挂机采集
./multi_mem_monitor.sh
# 等待 30 分钟(直到崩溃或手动停止)
  • 操作泊车测试(对应《操作泊车内存监测统计1小时》):
# 启动所有服务后,持续进行泊车操作(前进/后退/打方向)
./multi_mem_monitor.sh
# 持续操作 1 小时
步骤 6:CSV 转 Excel

采集得到的是 CSV 文件,转为 Excel 有两种方式:

方式 A:板端用 Python(如果板端有 Python 和 openpyxl)

import pandas as pd
df = pd.read_csv('multi_mem.csv')
df.to_excel('内存监测统计.xlsx', index=False)

方式 B:把 CSV 拷到电脑上用 Excel 打开

# 板端 → 电脑(通过 adb / scp / U盘)
scp root@<车机IP>:/path/to/multi_mem.csv ./
# 电脑上用 Excel 打开 CSV,另存为 .xlsx

3.3 你的两个 Excel 文件的特征对照

从你上传的数据来看:

两个文件都包含了 6 个服务的 VmRSS 数据,说明是用类似上面的 multi_mem_monitor.sh 批量采集的。

3.4 修复验证建议

部署修复版本后,用完全相同的采集脚本和参数重新采集 1 小时操作泊车数据,对比:

修复前:GODService 99820 KB → 134904 KB(+35084 KB / 小时)
修复后:GODService 预计 < 104000 KB(+<5000 KB / 小时)

如果泄漏量下降 85% 以上,说明修复有效。剩余的少量增长通常是:

  • glibc 分配器的正常内存碎片(不可完全消除)

  • 其他服务(如 PerceptionFusionService 泄漏 515M)对系统整体的影响

  • 内核页缓存的增长(/proc/meminfo 中的 Cached,可回收)

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐