
1. 内存泄漏不是“程序变慢”那么简单一个嵌入式C项目里真实发生的雪崩式故障去年冬天我接手一个运行在ARM Cortex-A9平台上的工业数据采集模块它本该7×24小时稳定运行但上线第三周开始出现诡异现象设备每隔48小时左右就自动重启一次日志里没有panic没有oops只有最后一行写着“systemd: watchdog timeout”。没人相信是内存问题——毕竟它只跑了三个线程每个线程逻辑简单malloc总量加起来不到2MB堆内存监控显示峰值也才3.2MB。直到我们用pmap -x pid连续采样72小时发现进程的RSS从3.2MB一路爬升到196MB而VSZ虚拟内存大小纹丝不动。那一刻我才意识到这不是“程序变慢”这是典型的堆内存泄漏引发的系统级雪崩。内存泄漏在嵌入式Linux C项目里从来不是教科书里“忘记free”的温柔提醒。它是静默的慢性毒药是资源耗尽前的最后一根稻草更是嵌入式系统最危险的“软性崩溃”诱因。它不报错不崩溃只悄悄吃掉你宝贵的RAM直到内核OOM killer被迫介入或者watchdog判定无响应而强制复位。更麻烦的是它往往和硬件驱动、中断上下文、信号处理、多线程锁竞争这些底层机制深度耦合导致valgrind这类通用工具在嵌入式环境里常常“失明”——不是它不行而是你的目标板根本跑不起来它的模拟器。所以这篇内容不讲“什么是内存泄漏”这种基础定义也不堆砌malloc/free配对的教条。我要带你钻进一个真实嵌入式C项目的毛细血管里看泄漏如何从一行看似无害的new开始在中断服务例程ISR里埋下伏笔在STL容器迭代器失效时悄然扩散在异常路径中彻底失控。我会告诉你为什么valgrind --toolmemcheck在ARM板上大概率失败以及当它失败时你手头真正能用的三把“手术刀”是什么会拆解malloc背后brk/sbrk/mmap三种系统调用的边界条件解释为什么/proc/pid/maps里的一段匿名映射区突然膨胀了50MB还会分享我在调试一个NFS挂载失败后引发的std::string构造泄漏时如何用gdb配合/proc/kpagecount反向追踪物理页归属的实操链路。如果你正在维护一个基于Buildroot或Yocto构建的嵌入式Linux根文件系统用C编写驱动适配层或数据处理模块那么这篇文章里的每一个案例、每一行命令、每一个配置参数都是我在产线踩坑后亲手验证过的。它不承诺“一键修复”但能确保你下次看到RSS持续上涨时知道该先敲哪条命令、该怀疑哪个模块、该在代码里加哪三行调试桩。2. 为什么valgrind在嵌入式Linux上经常“装死”从原理到替代方案的硬核拆解valgrind是内存泄漏检测的黄金标准这话没错——前提是你的目标平台支持它。但在嵌入式Linux世界里这个前提往往不成立。去年我调试一个基于i.MX6ULL的边缘网关固件时交叉编译valgrind后烧录进板子执行valgrind --toolmemcheck ./myapp结果只得到一句冰冷的valgrind: failed to start tool memcheck。翻遍日志发现根本原因是valgrind的动态二进制插桩dynamic binary instrumentation机制严重依赖宿主机CPU的指令集模拟能力而ARMv7-A架构的某些特权指令比如mcr/mrc对CP15协处理器的访问在valgrind的模拟器里无法被正确翻译导致初始化阶段直接abort。但这只是表象。更深层的原因在于valgrind的设计哲学与嵌入式约束的根本冲突内存开销不可接受valgrind运行时会为每个字节的用户内存分配额外4~8字节的元数据metadata用于记录访问状态。一个原本占用8MB堆内存的C应用在valgrind下可能瞬间膨胀到30MB以上。而我们的i.MX6ULL板载RAM仅512MB其中256MB要留给Linux内核和图形子系统留给用户空间的不足200MB。valgrind的内存税直接让系统OOM。性能惩罚过于残酷valgrind通过将原生指令翻译成中间表示IR再执行带来10~50倍的性能下降。一个正常10ms完成的数据包解析函数在valgrind下可能耗时500ms。这不仅让实时性要求如CAN总线周期性采样彻底失效更会导致看门狗超时复位你根本来不及看到泄漏报告。内核交互被阻断valgrind为了保证插桩一致性会拦截并重写所有系统调用syscall。但嵌入式驱动常通过ioctl直接与硬件交互或使用mmap映射设备寄存器。valgrind对这些非标准syscall的处理极不稳定轻则返回ENOSYS重则触发内核panic。所以当valgrind在你的嵌入式板子上“装死”时别急着骂它不兼容先问自己三个问题你的目标板CPU是否支持valgrind官方文档明确列出的架构目前仅完整支持x86/x86_64/ARM64ARM32支持有限且需特定内核补丁你的应用堆内存峰值是否超过板载RAM的15%如果是valgrind的内存开销会让你的调试环境比生产环境更早崩溃。你的代码是否重度依赖ioctl、mmap、sigaction等底层系统调用如果是valgrind的拦截层很可能成为新的故障源。提示不要在嵌入式板上强行编译valgrind。我试过给ARMv7-A打内核补丁并交叉编译最终在vgdb连接阶段卡死。省下三天时间去做三件更有效的事第一用/proc/pid/status和/proc/pid/maps做基线监控第二启用glibc内置的mtrace第三给关键模块加malloc_hook钩子。这三者组合效率远超一个跑不起来的valgrind。那替代方案到底是什么不是玄学是三把经过产线验证的“手术刀”2.1 第一把刀/proc/ /status /proc/ /maps 的组合拳这是零成本、零侵入、100%可用的起点。在你的嵌入式板子上只要Linux内核启用了CONFIG_PROC_FS默认开启你就能用它定位泄漏方向。# 获取进程基本信息重点关注VmRSS, VmSize, VmData cat /proc/$(pidof myapp)/status | grep -E Vm(RSS|Size|Data|Stk) # 查看内存映射详情识别异常增长的匿名映射区 cat /proc/$(pidof myapp)/maps | awk $6 ~ /^\[.*\]$/ {print $0} | sort -k5nr | head -10关键解读VmRSSResident Set Size是你真正占用的物理内存持续上涨就是泄漏铁证VmDataData Segment Size反映堆heap和未初始化数据段BSS大小若它和VmRSS同步上涨基本锁定为malloc/new泄漏/proc/pid/maps中[anon]标记的匿名映射区是malloc分配的主战场。如果某段[anon]的Size列从004000004MB涨到0320000050MB且Offset为00000000这就是泄漏源头的物理证据。我曾用这招在一个NFS挂载失败的案例中快速定位/proc/pid/maps显示一段[anon]从4MB暴涨至128MB而/proc/pid/stack显示该线程正卡在nfs_readdir的回调里。进一步检查发现NFS客户端在目录项解析失败时错误地将struct dirent指针链表的每个节点都new出来却未delete因为异常处理路径缺失。2.2 第二把刀glibc的mtrace——轻量级但精准的malloc追踪器mtrace是glibc内置的内存追踪工具无需交叉编译只需在代码中加两行编译时链接-ldl即可。它不模拟CPU不拦截syscall只在malloc/free/realloc调用点埋设钩子因此在嵌入式环境里稳定得像块石头。使用步骤极其简单在C源文件全局作用域添加#include mcheck.h #include stdio.h // 必须在main()之前调用且只能调用一次 void __attribute__((constructor)) init_mtrace() { setenv(MALLOC_TRACE, /tmp/mtrace.log, 1); mtrace(); }编译时确保链接-ldl动态链接库arm-linux-gnueabihf-g -O2 -g main.cpp -o myapp -ldl运行程序结束后用mtrace工具解析日志# 在宿主机x86_64上解析无需在板子上运行 mtrace ./myapp /tmp/mtrace.logmtrace的输出直击要害Memory not freed: ----------------- Address Size Caller 0x00012340 0x100 at /home/src/parser.cpp:45 0x00012450 0x200 at /home/src/network.cpp:128它精确到文件名和行号且完全不依赖目标板性能。去年我用它在一个基于Buildroot的嵌入式系统里30分钟内就定位到std::vectorstd::string在异常抛出时部分string对象的内部缓冲区未被析构的问题——因为vector的移动语义在异常路径中被绕过了。注意mtrace只跟踪malloc/free系列不跟踪new/delete。但C的new底层就是调用malloc所以只要你的operator new没被重载它就100%生效。如果重载了你需要在自定义operator new里手动调用malloc并记录。2.3 第三把刀malloc_hook——自己动手丰衣足食的终极方案当mtrace不够用比如需要记录调用栈、线程ID、分配上下文或者你想在不修改源码的情况下给第三方库加监控malloc_hook就是你的终极武器。它允许你在每次malloc调用前插入自定义逻辑堪称嵌入式内存调试的“上帝模式”。核心原理glibc提供四个可替换的函数指针void *(*__malloc_hook)(size_t size, const void *caller); void (*__free_hook)(void *ptr, const void *caller); void *(*__realloc_hook)(void *ptr, size_t size, const void *caller); void *(*__memalign_hook)(size_t alignment, size_t size, const void *caller);使用方法以malloc_hook为例#include execinfo.h #include pthread.h // 全局存储原始hook避免递归调用 static void* (*old_malloc_hook)(size_t, const void*) NULL; // 自定义malloc hook static void* my_malloc_hook(size_t size, const void* caller) { // 恢复原始hook防止递归 __malloc_hook old_malloc_hook; // 记录关键信息大小、调用地址、线程ID、调用栈 void* ptr malloc(size); if (ptr) { // 打印到串口或log文件嵌入式常用 printf([MALLOC] %p, size%zu, thread%lu, caller%p\n, ptr, size, (unsigned long)pthread_self(), caller); // 可选获取调用栈需编译时加-fno-omit-frame-pointer void* buffer[50]; int nptrs backtrace(buffer, 50); backtrace_symbols_fd(buffer, nptrs, STDERR_FILENO); } // 重新安装自己的hook __malloc_hook my_malloc_hook; return ptr; } // 初始化hook在main开头调用 void install_malloc_hook() { old_malloc_hook __malloc_hook; __malloc_hook my_malloc_hook; }这个方案的优势在于完全可控你可以把分配记录写入环形缓冲区避免I/O阻塞可以按线程ID聚合统计甚至可以在分配超过阈值时触发gdbserverattach。我在调试一个tdengineC绑定库的泄漏时就是靠它抓到了taos_stmt_prepare内部反复newSQLParser对象却未释放的bug——因为那个库的源码我无权修改但malloc_hook让我在二进制层面完成了监控。3. 嵌入式C里最隐蔽的泄漏温床STL容器、异常安全与中断上下文的三重陷阱在嵌入式C项目里泄漏很少来自裸写的malloc/free更多藏在看似安全的高级抽象之下。我见过太多团队把std::vector、std::map、异常处理、信号捕获当作“银弹”结果在产线运行数月后内存像退潮一样缓慢流失。下面这三个场景是我在过去三年里踩坑最多、也最值得警惕的“温床”。3.1 STL容器的“幽灵迭代器”erase操作后的悬垂指针C标准规定std::vector::erase会使被擦除元素之后的所有迭代器、引用、指针失效。但在嵌入式实时代码里开发者常忽略这一点写出如下“优雅”但致命的代码// 危险嵌入式数据处理循环 for (auto it data_queue.begin(); it ! data_queue.end(); it) { if (it-is_expired()) { // 错误erase后it立即失效但循环仍执行it data_queue.erase(it); // it now invalid! } }这段代码在x86桌面环境可能侥幸运行但在ARM Cortex-A9上erase后it指向的内存可能已被malloc回收并重用it操作会读取非法地址触发SIGSEGV。更隐蔽的是如果SIGSEGV被信号处理函数捕获而该处理函数又调用了std::string构造内部new就会形成“泄漏崩溃”的双重灾难。正确写法必须用erase的返回值// 安全erase返回下一个有效迭代器 for (auto it data_queue.begin(); it ! data_queue.end(); ) { if (it-is_expired()) { it data_queue.erase(it); // it now points to next element } else { it; } }但问题不止于此。std::vector的capacity()可能远大于size()即使你清空了所有元素capacity()不会自动收缩。一个频繁增删的队列capacity()可能从1000膨胀到10000占用大量内存却不释放。解决方案是主动收缩// 强制收缩capacity到size std::vectorDataItem(data_queue).swap(data_queue);3.2 异常安全的“阿喀琉斯之踵”资源获取即初始化RAII的断裂点RAII是C异常安全的基石但嵌入式开发中它常在两个地方断裂第一信号处理函数signal handler中调用非异步信号安全函数。POSIX标准明确规定malloc、printf、std::string构造等都不是异步信号安全的。但很多开发者在SIGUSR1处理函数里直接new一个日志对象结果信号到来时主线程正卡在malloc的互斥锁里导致死锁。更糟的是new失败时抛出std::bad_alloc而信号处理函数里不能抛异常程序直接abort。第二异常传播路径中的资源泄漏。考虑以下代码class DataProcessor { std::unique_ptrBuffer input_buf; std::unique_ptrBuffer output_buf; public: DataProcessor() : input_buf(new Buffer(4096)), output_buf(new Buffer(4096)) {} };表面看完美unique_ptr自动管理内存。但如果input_buf的new成功而output_buf的new失败抛出std::bad_allocinput_buf的析构函数不会被调用因为构造函数尚未完成对象未完全生成C标准规定此时只释放已成功构造的子对象但input_buf的new返回的原始指针不会被unique_ptr接管造成泄漏。解决方案是使用“两阶段构造”或std::make_uniqueC14起DataProcessor() { input_buf std::make_uniqueBuffer(4096); output_buf std::make_uniqueBuffer(4096); }make_unique是原子操作要么全部成功要么全部失败不存在中间态。3.3 中断上下文ISR与C对象的“禁忌之恋”这是嵌入式C最危险的雷区。很多开发者想在中断服务例程里用std::queue缓存数据或者new一个std::string记录事件结果系统在高负载下随机死机。原因有三中断上下文禁止睡眠malloc在内存不足时可能触发kswapd进行页面回收这是一个可睡眠操作而在中断上下文in_interrupt()为true中调用会导致内核panic。STL容器非实时安全std::queue的push可能触发内存重分配std::map的insert涉及红黑树旋转这些操作时间不可预测违反实时系统确定性要求。C全局对象构造时机不确定std::queue的静态对象可能在中断发生时尚未构造完成访问未初始化内存。正确做法是中断上下文只做最简操作——存入预分配的环形缓冲区ring buffer。所有C对象的创建、销毁、复杂计算必须在下半部tasklet、workqueue或用户线程中完成。// 中断处理函数ISR——只存不new不调用STL extern C irqreturn_t can_irq_handler(int irq, void *dev_id) { // 使用预分配的固定大小数组无malloc static uint8_t rx_buffer[1024]; static size_t head 0, tail 0; // 硬件寄存器读取 uint8_t data read_can_register(); rx_buffer[head] data; head (head 1) % sizeof(rx_buffer); // 触发下半部处理 schedule_work(can_work); return IRQ_HANDLED; } // 下半部工作队列可睡眠可调用malloc/STL static void can_work_func(struct work_struct *work) { // 此处可安全使用std::vector、std::string等 std::vectoruint8_t packet; while (tail ! head) { packet.push_back(rx_buffer[tail]); tail (tail 1) % sizeof(rx_buffer); } process_can_packet(packet); }这套模式在我们所有基于Linux的嵌入式项目中强制推行它把不确定性隔离在用户空间确保中断响应时间稳定在微秒级。4. 从malloc到brk深入glibc堆管理器的底层脉络与泄漏诊断线索要真正理解内存泄漏不能只停留在malloc/free的API层面必须向下穿透到glibc的堆管理器ptmalloc2和Linux内核的内存管理子系统。就像医生不能只看症状还要懂解剖。下面这张图文字描述就是malloc调用背后的完整链条用户代码: ptr malloc(1024) ↓ glibc ptmalloc2: 检查fastbins/unsorted_bins → 若有合适chunk直接返回 ↓ 否则 调用sbrk()或mmap()向内核申请新内存 ↓ 内核: brk系统调用 → 扩展进程数据段data segment边界 或 mmap系统调用 → 映射新的匿名内存页[anon] ↓ 物理内存: 内核页表更新分配物理页帧page frame ↓ 用户空间: ptr指向新分配的虚拟地址这个链条里每一个环节都可能是泄漏的“藏身之处”。而/proc/pid/maps和/proc/pid/smaps就是我们窥探这个链条的窗口。4.1 brk vs mmap两种分配策略的泄漏特征malloc并非总是调用sbrk。glibc有一个阈值MMAP_THRESHOLD默认128KB小于该值走sbrk大于则走mmap。这导致两种泄漏在/proc/pid/maps中呈现截然不同的形态sbrk泄漏小块分配表现为[heap]段持续增长。/proc/pid/maps中你会看到00010000-00020000 rw-p 00000000 00:00 0 [heap]如果这个区间的Size从0001000064KB涨到000a0000640KB说明brk边界被不断推高且free未将其拉回——这是典型的malloc未配对free。mmap泄漏大块分配表现为多个[anon]段无序增长。/proc/pid/maps中你会看到000b0000-000c0000 rw-p 00000000 00:00 0 [anon] 000d0000-000e0000 rw-p 00000000 00:00 0 [anon] 000f0000-00100000 rw-p 00000000 00:00 0 [anon]每个[anon]对应一次mmap(MAP_ANONYMOUS)调用。如果数量越来越多且每个大小固定如都是4KB很可能是某个循环里反复new小对象触发了mmap阈值。诊断技巧用pmap -x pid对比AnonHugePages和MMUPageSize字段。如果AnonHugePages为0说明分配的是普通4KB页如果非0说明启用了THPTransparent Huge Pages这在嵌入式系统中通常被禁用除非你明确开启了/proc/sys/vm/thp_enabled。4.2 /proc/ /smaps泄漏诊断的“CT扫描仪”/proc/pid/maps只告诉你“哪里”变了/proc/pid/smaps则告诉你“为什么”变。它为每个内存映射区提供详细统计其中最关键的三个字段是RssResident Set Size该映射区当前占用的物理内存页数KB。持续上涨是泄漏的直接证据。PssProportional Set Size该映射区占用的物理内存页数按共享比例折算例如10个进程共享的库每个进程的Pss只计1/10。它比Rss更能反映单个进程的真实内存消耗。MMUPageSize该映射区使用的页大小4KB或2MB。如果看到大量2MB页说明启用了THP需检查是否与泄漏相关。实战案例去年调试一个vscode c远程调试代理时发现/proc/pid/smaps中[heap]段的Rss从2MB涨到128MB但Pss几乎不变。这说明泄漏的内存是进程独占的而非共享库。进一步用cat /proc/pid/smaps | grep -A 10 ^[0-9a-f] | grep Rss发现[heap]段的Rss增长速度与std::string的append调用频率完全同步——原来string的reserve策略在嵌入式环境下过度乐观每次扩容都mmap一大块却未及时munmap。4.3 glibc堆调试malloc_stats与mallinfo的现场快照当/proc文件系统提供的信息还不够你需要glibc的内部诊断接口。malloc_stats()和mallinfo()是两个无需额外工具的“现场快照”函数。malloc_stats()打印到stderr包含system bytes向系统申请的总字节数、in use bytes当前已分配字节数、max system bytes历史最大申请量等。在嵌入式板子上你可以把它集成到一个SIGUSR2信号处理函数里随时触发extern C void sigusr2_handler(int sig) { malloc_stats(); // 输出到串口或log } signal(SIGUSR2, sigusr2_handler);运行中发送kill -USR2 pid立刻看到堆的健康状况。如果in use bytes持续增长而system bytes不变说明内存碎片化严重如果两者同步增长则是真泄漏。mallinfo()返回struct mallinfo可编程化分析struct mallinfo mi mallinfo(); printf(Total allocated: %d KB\n, mi.uordblks / 1024); printf(Free memory: %d KB\n, mi.fordblks / 1024); printf(Top chunk size: %d KB\n, mi.keepcost / 1024);关键指标keepcosttop chunk大小如果异常大1MB说明malloc的fastbins和unsorted_bins未能有效回收大量小块内存散落在各处形成“内存碎渣”。注意mallinfo在glibc 2.33中已被弃用推荐用malloc_info()输出XML格式但嵌入式系统多用老版本mallinfo仍是主力。5. 实战复盘一个NFS根文件系统挂载失败引发的连锁泄漏事故现在让我们把前面所有知识点串起来复盘一个真实发生的、影响深远的泄漏事故。这个案例完美融合了嵌入式Linux、NFS、C、STL和异常处理也是我职业生涯中最烧脑的一次调试。5.1 故障现象与初步排查设备基于Buildroot构建根文件系统通过NFS挂载nfs v3协议。上线初期一切正常但某天运维反馈设备在挂载NFS服务器宕机后连续运行72小时RSS从12MB涨到218MB最终OOM killer杀死进程。奇怪的是NFS恢复后RSS并未回落仿佛泄漏“固化”了。第一步/proc/pid/status确认VmRSS确实在涨。第二步/proc/pid/maps显示[heap]段从00010000涨到000d0000832KB但更刺眼的是出现了大量[anon]段每个大小000010004KB总数从12个涨到218个。这强烈暗示mmap泄漏。5.2 锁定泄漏源头mtrace与gdb的协同作战启用mtrace运行72小时后解析日志Memory not freed: ----------------- Address Size Caller 0x00012340 0x100 at /home/src/nfs_client.cpp:89 0x00012450 0x100 at /home/src/nfs_client.cpp:89 ... 0x0001a780 0x100 at /home/src/nfs_client.cpp:89所有泄漏都指向同一行nfs_client.cpp第89行。打开代码// nfs_client.cpp:89 std::string get_nfs_path(const std::string filename) { std::string path config.nfs_root / filename; // line 89 return path; }这行代码本身没问题但config.nfs_root是一个std::string而config对象是在NFS挂载失败时由一个全局ConfigManager单例的init()方法构造的。init()方法里有一段异常处理void ConfigManager::init() { try { load_from_nfs(); // 可能抛出异常 } catch (const std::exception e) { // 挂载失败加载默认配置 load_default_config(); // BUG这里应该记录错误但忘了清理临时对象 temp_config.reset(new Config()); // new出来的对象但reset后未delete } }temp_config是一个std::unique_ptrConfig但reset()传入new Config()后如果后续代码没调用reset(nullptr)或让其离开作用域Config对象就永远驻留在内存里。而Config类内部包含一个std::vectorstd::string每个string又new了缓冲区……于是形成了“泄漏链”。5.3 根本原因与修复方案根本原因有三层设计缺陷ConfigManager的init()方法在异常路径中创建了临时对象但未确保其生命周期结束STL滥用std::string的操作符在内部可能多次realloc每次realloc都mmap新页旧页未及时munmapNFS挂载失败的副作用挂载失败触发了异常路径而该路径在正常流程中从未被执行过因此测试遗漏。修复方案分三步立即止血在catch块末尾添加temp_config.reset();确保Config对象被析构。长期加固将temp_config改为局部变量利用RAII自动管理void ConfigManager::init() { try { load_from_nfs(); } catch (const std::exception e) { auto temp_config std::make_uniqueConfig(); // 局部变量作用域结束自动析构 load_default_config(*temp_config); } }监控兜底在main()循环中加入内存检查while (running) { // 主业务逻辑 do_work(); // 每10分钟检查一次RSS if (time_since_last_check 600) { check_memory_leak(); time_since_last_check 0; } }这次事故教会我最重要的一课在嵌入式系统里异常不是“异常”而是“常态”。NFS挂载失败、网络中断、传感器离线、电源波动——这些在桌面环境里罕见的事件在嵌入式现场每天都在发生。你的异常处理路径必须和主路径一样经过严格测试否则它就是最大的泄漏温床。6. 给嵌入式C开发者的七条军规从编码习惯到系统监控的全链路防御基于过去五年在十几个嵌入式Linux C项目中的踩坑经验我总结出七条不成文的“军规”。它们不是教条而是用重启次数、客户投诉和深夜debug换来的血泪教训。每一条都直指内存泄漏的命门且已在产线验证有效。6.1 军规一禁止在任何信号处理函数中调用malloc/new/printf信号处理函数signal/sigaction运行在中断上下文的变体中是内存泄漏和死锁的高发区。POSIX明确定义的异步信号安全函数只有约20个write,read,sigprocmask等malloc和printf都不在其中。我曾见过一个项目SIGUSR1处理函数里new一个LogEntry对象结果在高并发下malloc的互斥锁与主线程冲突导致整个进程挂起。正确做法是信号处理函数只设置一个volatile sig_atomic_t标志位主循环检测该标志并执行实际工作。6.2 军规二所有动态分配必须配对且配对点必须在同一作用域new/delete、malloc/free、mmap/munmap必须成对出现且最好在同一函数、同一代码块内。跨函数、跨线程的配对极易出错。例如一个函数create_buffer()返回new