B1 mergeEnvelope:命中 isNull() 的 3DSOLID 时 pSolidAll 已赋值但未从 arptrs 摘除, 后续 deleteArrayVoidPtrs 会把它删掉,返回野指针。改为记下标、循环结束后统一 removeAt。 B2 process_result:outputWithoutID.append(entAr) 误写在 for 循环体内,每轮把累积数组 整体再追加一次,同一指针在 outputWithoutID 中出现多次,AddToModelSpace 第二次失败后 DELETE_PTR 会删掉已入库的实体。append 移到循环外,并先 entAr.removeAll()。 B3 process_result:erase 之后把 it 重置成 begin(),for 的 ++it 会跳过新的首元素, 多个构筑物时漏处理。改为 while + 先取 next 迭代器。 同时更新 HRL-MODEL-refactor.md:去掉未实施的内存账本方案,验证方法改为行为观察。
15 KiB
HLR(相机剖切计算)模块问题记录与改造评估
范围:
SourceCode/Code2026/CoreLibrary/AecDbCamera.cpp(约 2900 行)。 基线:main @ 7870a2b(PR #76「修复相机 HLR 计算链路上的几处内存泄漏」已合并)。下文行号均为该版本。 本文记录四件事:已修的泄漏、尚未修的泄漏、已修的正确性缺陷,以及一次评估后放弃的改造(组合设备块展开)。
目录: 1 调用链与所有权约定 · 2 已修复的泄漏(PR #76) · 3 尚未修复的泄漏 · 4 已修复的正确性缺陷 5 已放弃的改造:组合设备块展开 · 6 验证方法
1. 调用链与所有权约定
1.1 一帧调用链
AecDbXCamera::refresh() :2121
├─ eraseEnts(m_arResults) 删上一轮输出
├─ collectEnities(arIds) / collectAxisObject(arAxis)
├─ getSectionPolygon() → new AcDbPolyline :2607,refresh 负责 delete
├─ 克隆轴网 → entAxis :2189 / :2215
│ 所有权隐式移交给 hlrCalc
├─ hlrCalc(arIds, ..., entAxis) :2023
│ ├─ preprocess(...) :907
│ │ ├─ [构筑物融合] mergeEnvelope() → pEnvelope :717
│ │ │ └─ 进 arAdditional_all + arAdditional
│ │ └─ 逐个实体 PreprocessForHlr() → 补充实体
│ │ └─ mpAdditional[id] = 补充实体,同时进 arAdditional_all
│ ├─ makeViewPort() → shared_ptr :440
│ ├─ HlrCalc(arIds, arAdditional_all, ...) :2066
│ │ result1: id → 结果;result2: 补充实体指针 → 结果
│ ├─ removeDegenerateArcs() :591
│ ├─ transfromHlrResult() :2086(会一起移动 pSection)
│ ├─ clipResultBySection() :241(删除并替换 result 内容)
│ ├─ process_result() → outputWithID / outputWithoutID :965(全是 clone)
│ └─ 释放 result1 / result2 / arAdditional_all :2106-2116
├─ DELETE_PTR(pSection)
└─ 输出:AddToModelSpace(),失败则 DELETE_PTR
1.2 三类数据
hlrCalc 的注释(:2035 附近)把参与 HLR 的数据分成三类,理解这三类就理解了整条链:
| 类别 | 来源 | 中间容器 | 谁释放 |
|---|---|---|---|
| 1 数据库实体 | arIds |
result1 |
hlrCalc :2108 |
| 2 数据库实体的补充实体 | PreprocessForHlr 产出 |
mpAdditional + result2 + arAdditional_all |
hlrCalc :2113 / :2116 |
| 3 非数据库实体的补充实体(融合后的构筑物) | mergeEnvelope 产出 |
arAdditional + result2 + arAdditional_all |
hlrCalc :2113 / :2116 |
1.3 所有权三条约定(改这个模块前必须先确认)
PreprocessForHlr/GetDraw3dables/AcDbBlockReference::explode/AcDbEntity::clone产出的都是纯堆对象,不在数据库里,调用方负责delete。mpAdditional(IDMapEntities)和arAdditional只做查找,全程没有任何释放它们的代码;真正的释放统一走arAdditional_all(:2116)。- 同一个指针只能进
arAdditional_all一次。因为deleteContainerOfArx是逐元素delete,重复入容器就会重复释放。preprocess里preEnt的项本来就已arAll.append过一次,所以不能再往preEnt里放同一批指针。
2. 已修复的泄漏(PR #76)
| 编号 | 位置 | 漏什么 | 触发条件 | 修法 |
|---|---|---|---|---|
| L1 | hlrCalc :2076 | arAdditional_all 全部 |
HLR 结果为空(实体全在视锥外 / 剖切面切空) | 早退前 deleteContainerOfArx |
| L2 | hlrCalc :2077 | arAxisPtrs(即 refresh 的 entAxis) |
同上,早退发生在插入 result1 之前 |
早退前 deleteContainerOfArx |
| L3 | hlrCalc :2094 | arAdditional_all 全部 |
截面裁剪后结果为空 | 早退前 deleteContainerOfArx |
| L4 | mergeEnvelope :774 | arptrs 里全部克隆实体 |
勾了"构筑物融合",但构筑物里没有可用的 3DSOLID(全是 Region / 网格 / 空 solid) | deleteArrayVoidPtrs 后再返回 |
| L5 | process_result :1123 | subAr 里整块 HLR 结果 |
CoreTools::makeBlockReference 失败(块表被锁 / 只读 / tbl->add 失败) |
失败时 deleteArrayVoidPtrs(subAr) |
L3 严格来说是防御性的:
clipResultBySection只清空it->second的内容、不erasekey,所以result1/result2的 map 不会变空,这处早退目前不可达。保留释放是为了以后改裁剪逻辑时不出事。
关于 L1/L2 的量级
补充实体是每个选中实体产出的(一条管道 1~3 个中心线/流向箭头,一个管件若干),所以这两处不是"漏一个对象",而是"漏一整个剖面的补充几何"。而且触发条件恰好是"刷新了但没出图"这种高频操作。
关于 L2 的所有权
refresh 在 :2189 / :2215 把轴网克隆进 entAxis,然后作为 arAxisPtrs 传给 hlrCalc:
- 正常路径:hlrCalc :2083 把它塞进
result1[kNull],最后被 :2108 的循环释放 —— 这是一次隐式所有权转移,refresh 自己从不释放。 - 所以早退路径必须由 hlrCalc 补释放。两个调用方(
refresh和AecDbJoggedCamera::Camera::hlrCalc,AecDbJoggedCamera.cpp:573)在 hlrCalc 返回后都不再使用这个数组,补释放是安全的。
3. 尚未修复的泄漏
L6 rollback 的 output 从不释放
AecDbCamera.cpp:638
AcDbEntityArray ar, output;
EntMapEntities eme;
for (...)
{
if (pEnt) pEnt->ProcessHlrResult(ar, eme, output); // ar 恒为空
if (pCrv) pCrv->ProcessHlrResult(ar, eme, output);
}
// output 没有回收
rollback 的实际目的是通过调 ProcessHlrResult 触发副作用(例如 AecDbEwPipe::ProcessHlrResult 会把 HLR_CALC 复位),并不是真的要结果。
因为 ar 恒为空,现有所有实现都不会往 output 里塞东西,所以目前确实不漏。但它是一个"结果容器"却没有任何回收,哪天某个 override 在空 ar 下也 append 就会开始漏。建议加一行 deleteContainerOfArx<AcDbEntity>(output); 兜底。
4. 已修复的正确性缺陷
下面三条都是原代码的问题,已在本分支修复。保留分析过程是为了说明为什么这么改。
B1 mergeEnvelope 可能返回悬空指针(use-after-free)
AecDbCamera.cpp:755-777
AcDb3dSolid *pSolidAll(NULL);
for (int i(0); i < arptrs.length(); ++i)
{
pSolidAll = AcDb3dSolid::cast(pEnt);
if (!pSolidAll) continue;
if (pSolidAll->isNull())
{
acutPrintf(_T("\nThe 3DSOLID does not have a ShapeManager object. "));
continue; // ← pSolidAll 已经非空,且没有 removeAt
}
arptrs.removeAt(i);
break;
}
if (!pSolidAll) { ... return NULL; }
if (arptrs.isEmpty()) return pSolidAll;
...
deleteArrayVoidPtrs<AcDbEntity>(arptrs); // ← 把这个 null-solid 也删了
return pSolidAll; // ← 返回已释放指针
触发条件:找到的"第一个 solid"恰好 isNull(),且之后再没有有效 solid。此时 pSolidAll 非空但指向一个空 solid,它仍留在 arptrs 里,被 :815 的 deleteArrayVoidPtrs 删掉,然后被当作结果返回。preprocess :921 立刻 xData.Write(pEnvelope, ...) → UAF。
已修复:改成“只记下标、循环结束后再 removeAt”——nSolidIndex 为 -1 表示没找到,pSolidAll 只在命中有效 solid 时才赋值。这样“指针非空”和“已从容器摘除”两件事不再脱节。
B2 outputWithoutID.append(entAr) 写在循环体内
AecDbCamera.cpp:1015
for (int i(0); i != arResult.length(); ++i)
{
AcDbEntity *pEnt = (AcDbEntity *)arResult[i]->clone();
...
entAr.append(pEnt);
outputWithoutID.append(entAr); // ← entAr 不清空,每轮把累积数组整体再 append
}
第 n 轮把累积数组整体追加一次,同一指针会在 outputWithoutID 里出现多次:
| 轮次 | entAr |
outputWithoutID |
|---|---|---|
| 1 | [e1] |
[e1] |
| 2 | [e1,e2] |
[e1, e1,e2] |
| 3 | [e1,e2,e3] |
[e1, e1,e2, e1,e2,e3] |
后果:refresh :2252 第二次 AddToModelSpace 必然失败,随后 DELETE_PTR(pEnt) 删掉已经入库的实体。
目前打不到:arAdditional 里只有带 _ENVELOPE_ 标记的融合实体,在 :998 走 continue 分支。属于埋着的雷,一旦以后往 arAdditional 里放别的补充实体就会炸。
已修复:在内层循环前加 entAr.removeAll(),把 outputWithoutID.append(entAr) 移到循环之后,并加 if (!entAr.isEmpty()) 保护。这样 entAr 的清空节奏和 append 节奏一致,每个实体只出现一次。
B3 outputWithID 的 erase 循环会跳元素
AecDbCamera.cpp:1157-1171
for (; it != outputWithID.end(); ++it)
{
if (!isEnvelope(it->first)) continue;
...
outputWithID.erase(it);
it = outputWithID.begin(); // 然后 for 的 ++it 再自增
}
erase 后重置到 begin(),紧接着 for 的 ++it 又走一步 → 跳过新的首元素。有多个构筑物 id 时会漏处理一个(不会被转成 arEnvelope,也不会从 outputWithID 里移除)。
已修复:改成 while 循环 + 显式 ++it;erase 之前先算好 itNext,erase 之后 it = itNext。
(没有用 it = outputWithID.erase(it) 的返回值,因为 ARX 20~22 走 C++03,那时 map::erase 返回 void。)
5. 已放弃的改造:组合设备块展开
本节内容未实施,仅作记录。
5.1 需求
组合设备(pl_zhsb 从库里插入的设备)在模型空间里是一个 AcDbBlockReference。相机做 HLR 时,这个块是整块参与计算的,结果又被 process_result 打回一个块。期望改成"把块内实体取出来,逐个参与 HLR",这样块内实体各自的剖切表现(中心线、系统颜色、遮挡关系)才能正确。
5.2 块的识别方式
标记写在 AcDbBlockReference 的 XData 上,注册应用名两套、机制相同:
| 来源 | 应用名 | 写入点 |
|---|---|---|
| 组合设备 | ZH_SB_REF_FIELD |
PipelineCmd/ZHSBLibDlg.cpp:351、PipelineCmd/EquipmentLibraryDlg.cpp:1148 |
| 模型库 | MODEL_3D_REF_FIELD |
PipelineCmd/Model3dLibDlg.cpp:351 |
判据就是"这个 XData 应用名存不存在",参考 CoreCommand/entityfilter.cpp:1419。不要在 CoreLibrary 里调 CEntityProperty::isZHSB(在 CoreCommand 层,反向依赖)。
5.3 关键结论(重做时可以直接用)
- 块 id 必须从
arids里摘掉。只要块 id 还在,HLR 就会整块算一遍,process_result里pRef分支(:1127 起)再makeBlockReference打回一个块,几何翻倍。preprocess的arids本来就是"会被挑选修改"的(arids = arOther已经这么干过)。 - 块内实体只能进
mpAdditional+arAdditional_all,绝不能同时进arAdditional,否则同一指针在arAdditional_all里出现两次 → 重复释放(见 1.3 第 3 条)。 - 叶子实体必须把自己也放进补充实体数组。
PreprocessForHlr只追加"补充"几何,实体本身是通过arids参与 HLR 的;块内实体没有 id,只能把自己也当补充实体塞进去。 - 要支持叶子的
ProcessHlrResult,需要两层结构。process_result的约定是ProcessHlrResult(自己的结果, 补充实体→结果, output),而mpAdditional只有一层。要正确组装,必须额外维护EntMapEntities 叶子→它的补充实体,并把它一路透传preprocess→hlrCalc→process_result(三个函数都在匿名 namespace 里,签名可以改)。 explode()的所有权:返回的是纯堆对象,delete即可释放;但只展开一层,嵌套块返回的仍是AcDbBlockReference,需要递归;MINSERT 会返回eNotApplicable,此时必须保留整块(否则几何直接消失)。仓库里AecDbOpening.cpp:2997 _explode是可参考的递归写法(它用 clone +transformBy,结果用deleteArrayVoidPtrs释放)。- 属性继承要版本兼容:
AcDbEntity::layer(AcString&)/linetype(AcString&)这两个重载 R23.0(ACAD 2019)才有,R18~R22 只有ACHAR* layer() const(要acutDelString)。想判"是否在 0 层"又不踩版本差异,可以用层表取"0"层的 id 再比layerId()(写法参考CoreLibrary/Geom.cpp里的getLayerTable)。
5.4 为什么放弃
改动要同时动 preprocess、process_result、hlrCalc 三个函数的签名,并引入一层新的 EntMapEntities 结构(叶子→补充实体),才能让块内上华实体走自己的 ProcessHlrResult。而收益只是"组合设备块剖切时的显示细节"。
期间实际踩出来的坑(都已复现并修掉):
arAll.append(累积数组)写在循环里 → 同一指针重复入arAdditional_all→ 多次delete,任何含 2 个以上实体的块必崩;- 返回值语义写反("处理成功"返回 false,"处理失败"返回 true),配上块 id 摘除后会导致整块几何消失;
preprocess_block内部又 open 了一次调用方已打开的实体。
结论:性价比不足,回退。等真的需要在剖切里区分组合设备内部构造时再重做。
6. 验证方法
本工程 Debug 也链接 Release CRT(cmakelists.txt 里 CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreadedDLL",且 Debug 会剥掉 _DEBUG),所以 _CrtSetDbgFlag / _CrtDumpMemoryLeaks 用不了,只能靠行为观察。
- 定点验证 L1/L2/L3:把相机移到完全没有实体的位置(或把剖切面拖到模型外)连续刷新 50 次。修之前每次都会攒下一轮补充实体与轴网克隆,任务管理器里私有内存单调上升;修之后应回到基线并保持平稳。
- 定点验证 L5:选中一个块参照参与剖切(块打块失败的场景不易构造,主要看正常路径的内存曲线不随刷新次数增长)。
- 回归(重点):B1~B3 改的是控制流,必须实测。
- 多个构筑物同时参与剖切(验证 B3 的 erase 循环不再跳元素,且每个构筑物都被正确处理);
- 块参照参与剖切(验证 B2 之后块内实体只被 append 一次,不会在后续刷新时出现"实体已被删除"的崩溃);
- 构筑物融合开 / 关各跑一遍,输出几何与修改前逐项比对。
- L1~L6 只加释放、不改数据流,理论上输出不变;只要出现几何缺失或崩溃,优先怀疑释放点误删了数据库内实体。