<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel>
<title>航图笔记</title>
<link>https://nightchart.cn</link>
<description>电子海图与地图渲染技术笔记：S-57 / S-52 / S-100 / MapLibre 源码走读</description>
<lastBuildDate>Tue, 08 Sep 2026 06:42:58 GMT</lastBuildDate>
  <item>
    <title>一次启动自死锁：单例重入引发的崩溃排查实录</title>
    <link>https://nightchart.cn/singleton-reentrant-deadlock.html</link>
    <guid>https://nightchart.cn/singleton-reentrant-deadlock.html</guid>
    <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
    <description>程序一启动就挂死：无崩溃、无日志、CPU 闲着。复盘一次单例构造期重入引发的死锁，从线程栈定位到三层修复，附一份防复发清单。</description>
    <content:encoded><![CDATA[<p>这篇不聊标准，聊一次真实的翻车。当时在给渲染引擎加一种新的数据图层，改动本身不复杂，编译一次通过，本地验证也正常。合入后的第二天早上，版本一起，程序直接&quot;石化&quot;：进程在，窗口白，没有崩溃报告，日志停在启动流程的某一行之后再无下文。</p>
<h2 id="sec-1">现象：不是崩溃，是&quot;石化&quot;</h2>
<p>排查先从三个特征排除最常见的嫌疑：</p>
<ul><li><strong>没有崩溃报告、没有异常栈</strong> → 不是空指针、越界那类硬崩</li><li><strong>CPU 占用接近 0</strong> → 不是死循环（死循环会吃满至少一个核）</li><li><strong>日志断流</strong> → 卡住的位置就在最后一行日志对应的下一步</li></ul>
<p>&quot;不干活但也不退出&quot;，加上 CPU 安静，基本可以断定：<strong>某个线程在等一把永远等不到的锁。</strong></p>
<h2 id="sec-2">十分钟定位：让线程栈说话</h2>
<p>这种&quot;石化&quot;，最快的定位方式不是加日志重跑，而是<strong>直接 attach 上去看线程栈</strong>——卡住的线程此刻就站在案发现场，不需要复现。</p>
<p>主线程的栈（简化后）长这样：</p>
<pre><code>  [等待中]  互斥量加锁 ...
  GetInstance()
  LayerManager::LayerManager()     ← 构造函数
  RefreshFineSource()
  GetInstance()                    ← 又一次
  OnMapReady()
  main()</code></pre>
<p>栈里出现了<strong>两帧 <code>GetInstance()</code>，中间夹着构造函数</strong>。翻译成人话：外层的 <code>GetInstance()</code> 正在初始化这个单例、还没返回；而初始化过程中的构造函数里，又有一层代码伸手去要这个还没出生的单例。</p>
<p>把闭环画出来，原因基本就锁定了：</p>
<p><img src="assets/fig4-deadlock-callchain.svg" alt="构造期重入调用链示意" loading="lazy"></p>
<p><em>图 1：构造函数内部再次调用 GetInstance()，请求回到还没完成的初始化守卫——自己等自己</em></p>
<h2 id="sec-3">根因：构造函数里伸手拿&quot;还没出生的自己&quot;</h2>
<p>出问题的代码模式，脱敏后长这样：</p>
<pre><code class="language-cpp"><span class="tok-kw">class</span> <span class="tok-type">LayerManager</span> {
<span class="tok-kw">public</span>:
  <span class="tok-kw">static</span> <span class="tok-type">LayerManager</span>&amp; <span class="tok-fn">GetInstance</span>() {
    <span class="tok-kw">static</span> <span class="tok-type">LayerManager</span> inst;   <span class="tok-com">// C++11 起：线程安全的&quot;魔法静态&quot;</span>
    <span class="tok-kw">return</span> inst;
  }

  <span class="tok-fn">LayerManager</span>() {
    <span class="tok-com">// 想着&quot;开箱即用&quot;，把刷新逻辑塞进了构造……</span>
    <span class="tok-fn">RefreshFineSource</span>();
  }

  <span class="tok-kw">void</span> <span class="tok-fn">RefreshFineSource</span>() {
    <span class="tok-kw">auto</span>&amp; self = <span class="tok-fn">GetInstance</span>();  <span class="tok-com">// 重入！此时初始化还没完成</span>
    <span class="tok-com">// ...</span>
  }
};</code></pre>
<p>关键在 <code>static LayerManager inst;</code> 这一行。C++11 起，局部静态变量的初始化是线程安全的：编译器会生成一个<strong>初始化守卫</strong>，第一个到达的线程把门关上开始构造，其他线程在门口排队。</p>
<p>那同一线程在初始化过程中再次进来呢？标准在这里写得毫不留情——<strong>行为未定义</strong>（[stmt.dcl]：如果控制流在变量初始化完成前递归地再入这条声明……）。而主流实现（MSVC / GCC / Clang）守卫的实现方式决定了：重入的线程，哪怕就是初始化线程自己，也会在门口等待。</p>
<p>于是：构造线程在等守卫放行，守卫在等构造完成。<strong>没人动，进程石化。</strong></p>
<p><img src="assets/fig5-guard-reentry.svg" alt="magic static 守卫：并发安全，重入死锁" loading="lazy"></p>
<p><em>图 2：同一个守卫，多线程并发是安全的，同线程重入就是死锁</em></p>
<p>顺带把两个容易混淆的问题分清：这不是 static initialization order fiasco（跨编译单元全局变量的初始化顺序问题），那个是&quot;谁先构造&quot;的玄学；这里是<strong>单例初始化过程中的重入</strong>，确定性的死锁，每次必现——反而好抓。</p>
<h2 id="sec-4">修复：三层加固</h2>
<p><strong>第一层：构造瘦身（治本）。</strong> 构造函数只做成员初始化，所有&quot;干活&quot;的逻辑挪出去，改成显式两阶段：构造完成、对象可用之后，由初始化流程显式调 <code>Init()</code>。</p>
<p><strong>第二层：未就绪先暂存。</strong> 现实里总有调用时机绕不开初始化窗口——异步线程、外部回调，你控制不了谁先到。对这类调用不再硬闯单例，而是先入队，初始化完成后按序补放。真实工程里&quot;安装记录未就绪时暂存、就绪后补写&quot;的改法就是这个思路。</p>
<p><strong>第三层：指针显式置空兜底。</strong> 可能被其他线程读到的成员指针一律显式初始化为 <code>nullptr</code>，让外部的判空逻辑能快速跳过，而不是撞上一个&quot;半初始化&quot;的对象。</p>
<p>修复后的示意：</p>
<pre><code class="language-cpp"><span class="tok-kw">class</span> <span class="tok-type">LayerManager</span> {
<span class="tok-kw">public</span>:
  <span class="tok-kw">static</span> <span class="tok-type">LayerManager</span>&amp; <span class="tok-fn">GetInstance</span>() {
    <span class="tok-kw">static</span> <span class="tok-type">LayerManager</span> inst;    <span class="tok-com">// 构造函数里再无任何&quot;重活&quot;</span>
    <span class="tok-kw">return</span> inst;
  }

  <span class="tok-kw">void</span> <span class="tok-fn">OnDataReady</span>(<span class="tok-kw">const</span> <span class="tok-type">TileData</span>&amp; d) {
    <span class="tok-type">std::lock_guard</span>&lt;<span class="tok-type">std::mutex</span>&gt; <span class="tok-fn">lk</span>(mtx_);
    <span class="tok-kw">if</span> (!ready_) {               <span class="tok-com">// 未就绪：先记账，不硬闯</span>
      pending_.<span class="tok-fn">push_back</span>(d);
      <span class="tok-kw">return</span>;
    }
    <span class="tok-fn">Apply</span>(d);
  }

  <span class="tok-kw">void</span> <span class="tok-fn">FinishInit</span>() {            <span class="tok-com">// 初始化完成点（由 Init 流程调用）</span>
    <span class="tok-type">std::lock_guard</span>&lt;<span class="tok-type">std::mutex</span>&gt; <span class="tok-fn">lk</span>(mtx_);
    ready_ = <span class="tok-kw">true</span>;
    <span class="tok-kw">for</span> (<span class="tok-kw">auto</span>&amp; d : pending_) <span class="tok-fn">Apply</span>(d);   <span class="tok-com">// 按序补放</span>
    pending_.<span class="tok-fn">clear</span>();
  }

<span class="tok-kw">private</span>:
  <span class="tok-fn">LayerManager</span>() = <span class="tok-kw">default</span>;      <span class="tok-com">// 瘦身：只初始化成员，指针一律 nullptr</span>
  <span class="tok-kw">void</span> <span class="tok-fn">Apply</span>(<span class="tok-kw">const</span> <span class="tok-type">TileData</span>&amp; d);

  <span class="tok-type">std::mutex</span> mtx_;
  <span class="tok-type">std::atomic</span>&lt;<span class="tok-kw">bool</span>&gt; ready_{<span class="tok-kw">false</span>};
  <span class="tok-type">std::vector</span>&lt;<span class="tok-type">TileData</span>&gt; pending_;
};</code></pre>
<p><img src="assets/fig6-fix-compare.svg" alt="修复前后对比" loading="lazy"></p>
<p><em>图 3：构造只管&quot;出生&quot;，干活挪到显式阶段；初始化窗口内的调用先暂存、就绪后补放</em></p>
<h2 id="sec-5">防复发清单</h2>
<p>这类问题真正的麻烦在于：它经常<strong>不发作</strong>。改代码的时候恰好没人重入，一切正常；某天别处加了个调用时机，雷就响了。所以 review 时直接对照清单：</p>
<ul><li>构造函数里<strong>不调用任何可能回头拿单例/全局对象的函数</strong>——间接的也算，顺着调用链往下多看两层</li><li>构造函数里不启动线程、不注册回调——回调可能在你构造完成之前就到达</li><li>构造函数里不打日志——日志系统自己往往也是单例</li><li>两个单例互相引用要格外警惕：A 的构造调 B，B 的构造调 A，结果取决于谁先进门</li><li>&quot;启动必现的挂死&quot;先看线程栈再动手，不要先加日志重跑——有些问题重跑一次就复现不出来了</li></ul>
<h2 id="sec-6">小结</h2>
<ul><li><strong>现象识别</strong>：无崩溃 + CPU 安静 + 日志断流 ≈ 在等一把永远等不到的锁，直接 attach 看线程栈</li><li><strong>根因</strong>：<code>static</code> 局部变量的初始化守卫<strong>不防重入</strong>——初始化线程自己再进来就是死锁（标准定义为未定义行为，主流实现的表现就是死锁）</li><li><strong>药方</strong>：构造瘦身两阶段、未就绪暂存补放、指针显式置空。三件事都不难，难的是写代码和 review 的时候能想起来</li></ul>
<p>按计划，下一篇回到标准主线《S-57 数据解剖：把一个 ENC 文件拆给你看》；踩坑实录这个系列也会继续——真实的工程问题，比标准条文好消化。</p>
<hr>
<p>我是夜航海图，做海图与地图渲染开发的工程师。博客「航图笔记」同步更新全部文章，欢迎 RSS 订阅。你也踩过单例的坑吗，评论区聊聊。</p>]]></content:encoded>
  </item>
  <item>
    <title>S-57 数据解剖：把一个 ENC 文件拆给你看</title>
    <link>https://nightchart.cn/s57-enc-anatomy.html</link>
    <guid>https://nightchart.cn/s57-enc-anatomy.html</guid>
    <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
    <description>拿真实 ENC 文件动刀：ISO 8211 封装、FRID/VRID 双表结构、共边拓扑、增量更新机制，附一个最小解析器的骨架和踩坑清单。</description>
    <content:encoded><![CDATA[<p>上一篇我们把 S-57 定性成&quot;海图的数据库表结构&quot;，很多朋友说不过瘾：文件到底是什么样的？这篇就动真刀。我们拿 IHO S-64 官方测试数据里的一个 ENC 单元，从第一层封装一路拆到单个要素的属性和坐标，最后给出一个最小解析器的骨架。</p>
<h2 id="sec-1">第一刀：文件根本不是文本</h2>
<p>用编辑器打开一个 ENC 文件，你会看到一屏乱码。它不是 JSON，没有花括号，一切信息靠<strong>字节位置和描述符</strong>表达。这套封装叫 <strong>ISO 8211</strong>——一个上世纪定下的自描述交换格式，今天仍然是 S-57 的载体。</p>
<p>ISO 8211 文件的结构只有两层概念：</p>
<ul><li>文件第一条记录是 <strong>DDR</strong>（数据描述记录），相当于 schema：它声明了后面所有记录里有哪些字段、每个字段什么格式；</li><li>之后是若干条 <strong>DR</strong>（数据记录），相当于数据行。</li></ul>
<p>心智模型：<strong>DDR 是表头，DR 是一行行数据</strong>。解析器的第一件事永远是读 DDR，否则后面一个字节都读不懂。</p>
<h2 id="sec-2">第二刀：交换集的全景</h2>
<p>单个 ENC 文件不孤立，交付形态是一个<strong>交换集</strong>（Exchange Set）：</p>
<pre><code>CATALOG.031        ← 目录文件：清单与更新链
GB500001.000       ← 基础单元（.000）
GB500001.001       ← 第 1 期更新
GB500001.002       ← 第 2 期更新</code></pre>
<p>单元文件名本身有信息量：开头两位是国家码，第三位是<strong>航行用途</strong>（Navigational Purpose，1–6），后面是单元序号；末尾三位是更新号，<code>.000</code> 表示基础版。航行用途 1 到 6 覆盖从大洋概图到靠泊图，数字越大比例尺越大——这也直接决定了你渲染引擎的缩放级别该加载哪些单元。</p>
<p>目录文件 CATALOG.031 里每个文件一条 <strong>CATD</strong> 记录：文件名、类型（基础还是更新）、更新日期。接收端靠它保证更新链完整——缺一环，后面全作废。</p>
<h2 id="sec-3">第三刀：记录的&quot;四大家族&quot;</h2>
<p>剥开 ISO 8211，S-57 的记录就四类。理解了它们的分工，文件在你眼里就从乱码变成了结构：</p>
<p><strong>1. DSID——数据集说明。</strong> 全局参数都在这：数据集名、深度单位，还有一个关键角色 <strong>COMF</strong>（坐标放大因子）。S-57 里坐标是整数，真实经纬度 = 整数值 ÷ COMF，通常 COMF = 10⁷。也就是说你在文件里看到 <code>43200000</code>，实际是东经 4.32°。所有坐标精度问题都从这条换算开始。</p>
<p><strong>2. CATD——目录条目。</strong> 上面说过了，只出现在目录文件里。</p>
<p><strong>3. FRID——要素记录（Feature）。</strong> 回答&quot;这里是什么&quot;。关键字段：</p>
<ul><li><strong>PRIM</strong>：几何原型，点 / 线 / 面 / 制图面四选一</li><li><strong>OBJL</strong>：对象类编码，整数。比如 DEPARE（深度区）是 42，全世界按同一本字典解码</li><li><strong>FOID</strong>：全局唯一 ID，机构码（AGEN）+ 两个编号构成三元组</li><li><strong>ATTF</strong>：属性键值对。DEPARE 身上一定挂着 DRVAL1 / DRVAL2——这片水域的水深范围</li></ul>
<p><strong>4. VRID——空间记录（Vector）。</strong> 回答&quot;它在哪里&quot;。VRID 标明自己是节点、边还是面；边的几何是 SG2D 字段里的一串坐标点。</p>
<p><strong>两条胶水字段</strong>把两张表缝起来：要素记录里的 <strong>FSPT</strong> 指向它引用的空间记录；空间记录之间的 <strong>VRPT</strong> 表达拓扑关系（边的两端节点、面的边界由哪些边组成）。</p>
<p>来拼一个真实的 DEPARE：</p>
<pre><code>FRID:  PRIM=A(面)  OBJL=42(DEPARE)
FOID:  AGEN=550   FIDN=1234  FIDS=1
ATTF:  DRVAL1=0   DRVAL2=20        ← 0~20 米的水域
FSPT:  → VRID #88(边)  → #89  → #90  → #91   （面由四条边围成）

VRID #88:  边，SG2D: (315200000, 1206500000) …  ← 一串 1/10⁷ 度坐标
VRPT:  起节点 #12 → 终节点 #13                 ← 边知道自己接在哪</code></pre>
<h2 id="sec-4">为什么边要单独存：共边拓扑</h2>
<p>看到这里你可能会问：一个面带自己的边界环不就完了，为什么 S-57 要把&quot;边&quot;提升成独立记录？</p>
<p>因为 <strong>ENC 要求平面拓扑（planar graph）</strong>：相邻两个深度区，共享物理上同一条边记录，而不是各存一份。</p>
<p>这件事的直接收益是一致性：边移动，两侧区域同时变形，不可能出现裂缝或重叠。S-58 校验标准里专门有一组检查盯着悬空边和几何缝隙。</p>
<p>而对你这个写渲染器的人来说，代价是加载逻辑复杂了：<strong>先把 VRID 表读全、把 FSPT/VRPT 关系建好，才能拼出完整的面</strong>。习惯了 GeoJSON 那种&quot;一个 Feature 自带一切&quot;的嵌套模型，转向 S-57 的&quot;两表 + 指针&quot;模型，这是第一个思维弯道。</p>
<p>顺带一提，SOUNDG（水深点组）是个例外配置：它是一组<strong>三维</strong>点，Z 值直接存水深——这大概是整个 S-57 里离&quot;一个对象一条几何&quot;最近的东西。</p>
<h2 id="sec-5">渲染器视角的常用对象类</h2>
<p>对象目录里 180 多个类，日常打交道的高频的就这十几个：</p>
<table><thead><tr><th>对象类</th><th>含义</th><th>渲染时的角色</th></tr></thead><tbody><tr><td>DEPARE</td><td>深度区</td><td>按 DRVAL1/DRVAL2 分档填色，浅水蓝、深水白</td></tr><tr><td>DEPCNT</td><td>等深线</td><td>VALDCO 取值，安全等深线加粗加黑</td></tr><tr><td>SOUNDG</td><td>水深点组</td><td>数字注记排版，可按疏密度抽稀</td></tr><tr><td>COALNE</td><td>海岸线</td><td>岸线符号</td></tr><tr><td>LNDARE</td><td>陆地区</td><td>填充底色，上面叠地名注记</td></tr><tr><td>LIGHTS</td><td>灯标</td><td>光弧（SECTR1/2）、节奏（SIGGRP/SIGPER）</td></tr><tr><td>BOYLAT 等</td><td>浮标系列</td><td>按类别/形状挂符号，读颜色属性</td></tr><tr><td>WRECKS / OBSTRN / UWTROC</td><td>危险物</td><td>属性组合决定危险样式</td></tr><tr><td>M_COVR / M_QUAL</td><td>元要素</td><td>不常规渲染，但覆盖范围、数据质量必须特殊处理</td></tr></tbody></table>
<p><code>M_</code> 开头的元要素再强调一遍：<strong>忽略 M_COVR 和 M_QUAL 是新手最常见的第一个 bug</strong>，它们决定你该加载哪些邻接单元、数据精度声明画在哪。</p>
<h2 id="sec-6">更新机制：.001 文件里是什么</h2>
<p>更新文件和基础单元结构完全一样，区别在于每条记录多了更新指令 <strong>RUIN</strong>：</p>
<ul><li><strong>1 = 插入</strong>：完整的要素+几何，直接并入</li><li><strong>2 = 删除</strong>：只带目标要素的 FOID，接收端按号移除</li><li><strong>3 = 修改</strong>：带新属性或新几何，替换对应记录</li></ul>
<p>三条工程纪律，每条都对应一类线上事故：</p>
<ol><li><strong>按序应用</strong>：<code>.001</code> 必须先于 <code>.002</code>，乱序 = 图面错乱</li><li><strong>幂等防重</strong>：同一期更新应用两次，结果不可预期，要做好已应用标记</li><li><strong>累积上限</strong>：更新号编到 999 会发重制版（reissue），文件管理逻辑要能识别</li></ol>
<p>S-64 测试数据集里有专门的更新用例，验证你的更新逻辑是免费的考卷。</p>
<h2 id="sec-7">最小解析器骨架</h2>
<p>把上面所有内容收拢成一个骨架，你会发现 S-57 解析本质上就是&quot;读 schema → 分发表 → 两张表 → 建关联&quot;：</p>
<pre><code>读 DDR，拿到字段字典

for 每条数据记录:
    switch 记录里的标识字段:
        DSID → 存全局参数（记住 COMF）
        CATD → 登记文件清单
        FRID → features 表插入一行（OBJL/FOID/ATTF）
        VRID → spatials 表插入一行（类型/坐标）

for f in features:
    按 FSPT 把 f 关联到引用的 spatial
for s in spatials:
    按 VRPT 建边-节点、面-边关系

渲染时：遍历 features，按 OBJL+属性查 S-52 look-up 表</code></pre>
<p>实操建议还是上一篇那句：<strong>先用 GDAL 的 S-57 驱动跑通全链路</strong>，把 ENC 转成 GeoJSON 对照着看，建立直觉；等你需要性能或特殊控制时，再下到 ISO 8211 字节层自己写——那时你已经知道每张表该长什么样了。</p>
<h2 id="sec-8">小结</h2>
<ul><li>ENC = ISO 8211 封装的记录流：DDR 是 schema，DR 是数据</li><li>记录四大家族：DSID（全局参数）、CATD（目录）、FRID（是什么）、VRID（在哪里），FSPT/VRPT 负责缝合</li><li>坐标是整数 ÷ COMF；平面拓扑要求共边；SOUNDG 三维点是例外</li><li>更新 = RUIN 三指令的有序序列，乱序和重复是事故重灾区</li><li>学习路径：GDAL 先跑通，字节层后下潜</li></ul>
<p>下一篇按节奏进入 MapLibre 系列：《mbgl 架构总览：线程模型、瓦片调度与渲染管线》——从标准的世界回到代码的世界。</p>
<hr>
<p>我是夜航海图，做海图与地图渲染开发的工程师。博客「航图笔记」同步更新全部文章，欢迎 RSS 订阅。你的 ENC 解析卡在哪一层，评论区聊聊。</p>]]></content:encoded>
  </item>
  <item>
    <title>电子海图开发入门：S-57、S-52、S-100 到底是什么</title>
    <link>https://nightchart.cn/ecdis-standards-roadmap.html</link>
    <guid>https://nightchart.cn/ecdis-standards-roadmap.html</guid>
    <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
    <description>给开发者的电子海图标准全景地图：S-57 管数据、S-52 管显示、S-100 是下一代框架，附一条可执行的学习路线。</description>
    <content:encoded><![CDATA[<p>把手机地图一路放大，你会看到更细的路网、更多的店铺——画什么、怎么画，是产品设计师说了算的。但把一张电子海图放大，规则完全不同：某片水域是深蓝还是浅蓝、某条线用实线还是虚线、危险沉船亮不亮，都不是渲染引擎&quot;觉得好看&quot;就行，而是国际标准一个字一个字写死的。</p>
<p>原因很朴素：<strong>海图不是给人逛的，是给船员做决策用的。</strong> 颜色错一档、深浅差一档，代价可能就是搁浅。所以 IMO（国际海事组织）认可的 ECDIS（电子海图显示与信息系统）必须严格按 IHO 标准来渲染，没有自由发挥的空间。</p>
<p>这决定了海图开发的知识结构和普通地图开发不太一样。除了渲染引擎本身，你必须懂两套标准，外加一场正在发生的换代：</p>
<ul><li><strong>S-57</strong> —— 数据：一张海图以什么结构存储和交换</li><li><strong>S-52</strong> —— 显示：每个要素在屏幕上长什么样</li><li><strong>S-100</strong> —— 框架：下一代标准体系，新产品都挂在它下面</li></ul>
<p><img src="assets/fig1-standards-map.svg" alt="S-57、S-52、S-100 三者关系示意图" loading="lazy"></p>
<p><em>图 1：S-57 管数据，S-52 管显示，S-100 是承载新产品族的下一代框架</em></p>
<p>这篇文章把三者的关系讲清楚，文末给一条可执行的学习路线。</p>
<h2 id="sec-1">S-57：海图的&quot;数据库表结构&quot;</h2>
<p>一句话概括：S-57 规定了海图数据的组织方式和交换格式。</p>
<p>心智模型很简单——<strong>整张海图就是一堆&quot;要素&quot;（Feature）</strong>，每个要素由三部分组成：</p>
<ul><li><strong>对象类（OBJL）</strong>：这是一个字典化的枚举，比如 DEPARE（深度区）、SOUNDG（水深点组）、COALNE（海岸线）、WRECKS（沉船）。每个对象类有唯一编码，全世界的 ECDIS 都按同一本字典解析。</li><li><strong>属性（ATTF）</strong>：挂在对对象上的键值对。比如 DEPARE 带 DRVAL1/DRVAL2（这个深度区的最小/最大水深），SOUNDG 带 VALSOU（水深值）。</li><li><strong>几何</strong>：只有点、线、面三种。而且要素之间存在拓扑——相邻两个深度区共享同一条边界。这也解释了为什么 S-57 数据里&quot;边&quot;要单独存，不是每个面各存一份。</li></ul>
<p>它带来的直接推论是：<strong>渲染海图不是&quot;画点线面&quot;，而是&quot;解释属性&quot;</strong>。一块水域是什么颜色，取决于 DEPARE 的深度区间落在哪个档；一个沉船要不要加危险标记，取决于它的属性组合。数据里没有任何颜色信息。</p>
<p><img src="assets/fig2-depare.svg" alt="DEPARE 要素从数据到屏幕的渲染链示意" loading="lazy"></p>
<p><em>图 2：一块 0–10 米的水域如何变成屏幕上的浅蓝色——颜色是查表查出来的</em></p>
<p>交换层面，一个 ENC 交换集通常长这样：一个目录文件（CATALOG.031）+ 基础单元（<code>.000</code>）+ 若干增量更新文件（<code>.001</code>、<code>.002</code>……）。注意更新不是重发一张新图，而是&quot;增/删/改&quot;记录的序列，接收端必须按序正确应用——实际工程里相当一部分坑都出在更新机制上。</p>
<p>另外还有一类 <code>M_</code> 开头的元要素（如 M_COVR 图幅覆盖范围、M_QUAL 数据质量），它们不参与常规渲染，但 ECDIS 必须特殊处理，忽略它们是新手最常见的第一个 bug。</p>
<p>给开发者的捷径：<strong>先别啃标准的 ISO 8211 封装层</strong>。用 GDAL 的 S-57 驱动把 ENC 转成 GeoJSON 或者塞进数据库，对着真实数据翻对象目录，比干读 PDF 快十倍。</p>
<h2 id="sec-2">S-52：把数据库&quot;翻译&quot;成屏幕</h2>
<p>一句话概括：S-52 规定了每个要素画成什么样，而且细到色值和像素。</p>
<p>它的核心是三件套：</p>
<p><strong>1. Look-up 表</strong>：决定&quot;对象类 → 显示模板&quot;的映射。比如 DEPARE 按 DRVAL1/DRVAL2 落进哪个深度档，用哪个填充模板；浮标类按其类别挂哪个符号模板。这张表是标准的一部分，不能自己发明。</p>
<p><strong>2. 符号库 + 命名色 + 色盘</strong>：颜色不是 RGB 随手定的。IHO 定义了一套命名色 token，再给出昼（DAY）、昏（DUSK）、夜（NIGHT）三套色值表，外加单色模式。渲染时引用色 token，切色盘就是换一套色值重渲——这就是海图&quot;昼夜模式&quot;的实现方式，也是为什么符号必须按色盘动态着色而不是贴死图片。</p>
<p><strong>3. 船员可调参数（Mariner Parameters）</strong>：这是最容易被普通地图开发者忽略的一点。安全等深线、安全水深、浅水/深水阈值，是<strong>船员的设置，不是图的属性</strong>。同一张 ENC，安全等深线设 10 米和 20 米，屏幕上&quot;安全水域&quot;的范围完全不同。</p>
<p>第三点意味着一个架构级约束：<strong>颜色不能烘死在瓦片里</strong>。阈值一改，相关图层要能重新着色、即时生效。习惯了&quot;瓦片=不可变缓存&quot;的 Web 地图思路，在这里会撞墙。这个约束怎么在矢量瓦片架构下优雅地解决，我后面会单独写一篇。</p>
<p>顺带一提，S-52 还定义了三级显示模式（Base / Standard / Full），控制哪些要素默认显示——这属于&quot;标准给的免费功能&quot;，实现成本很低，别漏。</p>
<p><img src="assets/fig3-palette.svg" alt="命名色 token 与昼/昏/夜三套色盘示意" loading="lazy"></p>
<p><em>图 3：切色盘 = 同一批命名色换一套 RGB；注意&quot;黑&quot;这个 token 夜间会翻白</em></p>
<h2 id="sec-3">S-100：正在发生的换代</h2>
<p>一句话概括：S-100 是下一代数据框架，S-57/S-52 是它要逐步接替的上一代。</p>
<p>S-57 的老问题：对象模型是封闭枚举，想加新要素类型就得改标准本身；只能表达矢量点线面；目录版本升级牵一发动全身。</p>
<p>S-100 的思路完全不同：<strong>注册制 + 要素概念字典</strong>。各产品规范自定义要素类型，注册进框架即可互相组合，还支持栅格、影像乃至三维。在这套框架下冒出来一整个产品家族：</p>
<table><thead><tr><th>产品</th><th>内容</th></tr></thead><tbody><tr><td>S-101</td><td>下一代电子海图（ENC）</td></tr><tr><td>S-102</td><td>高精度水深表面</td></tr><tr><td>S-104</td><td>动态水位</td></tr><tr><td>S-111</td><td>表层流</td></tr><tr><td>S-124</td><td>航行警告</td></tr><tr><td>S-125</td><td>航标（AtoN）</td></tr><tr><td>S-127</td><td>船舶交通管理</td></tr><tr><td>S-131</td><td>港口设施</td></tr></tbody></table>
<p>现实判断给两条：其一，监管层面允许双标准长期并存，所以 <strong>S-57 与 S-101 的同屏渲染、数据转换、目录映射，会是未来很多年这个行业的真实工作量</strong>；其二，S-100 系列的中文资料少到可以视为空白——如果你在做技术选型或者转型规划，这是个值得注意的信号，也是本博客选择持续写它的原因。</p>
<h2 id="sec-4">学习路线（也是本系列的地图）</h2>
<p><strong>第一步：跑通现成的。</strong> 装 OpenCPN，加载 IHO S-64 官方测试数据，对着屏幕理解&quot;数据 → 显示&quot;的完整链路；再用 GDAL 命令行把同一个 ENC 导出成 GeoJSON，看看对象、属性、几何是怎么落地的。</p>
<p><strong>第二步：按需读标准。</strong> 不要从头到尾通读。S-57 的 Appendix A（对象目录）当字典查；S-52 先只读显示流程那几张图；S-64 留着——等你写了渲染代码，它是检验对错的考卷。</p>
<p><strong>第三步：造一个最小渲染器。</strong> ENC → GeoJSON/矢量瓦片 → MapLibre 样式化。别小看这个玩具，look-up 表怎么实现、色盘怎么切换、船员参数怎么做到运行时生效——这些真正没资料的环节都会在路上遇到。本系列接下来的文章就是沿这条路走读。</p>
<h2 id="sec-5">资源清单</h2>
<ul><li>IHO 官网（iho.int）：S-52、S-57、S-64、S-100 系列标准 PDF 全部免费下载</li><li>IHO S-64 测试数据集：做开发和验证最合规的示例数据来源</li><li>OpenCPN：开源 ECDIS，最快的&quot;所见即所懂&quot;工具</li><li>GDAL：S-57 驱动，数据解析瑞士军刀</li><li>MapLibre：开源地图渲染引擎，本系列实战部分的主力</li></ul>
<p>（本站不卖课不带货，不会出现&quot;xxx 训练营&quot;链接。）</p>
<h2 id="sec-6">小结</h2>
<p>三句话带走：</p>
<ul><li><strong>S-57 管数据</strong>：要素 = 对象类 + 属性 + 几何，颜色不在数据里</li><li><strong>S-52 管显示</strong>：look-up 表 + 命名色色盘 + 船员参数，一切皆规则</li><li><strong>S-100 管未来</strong>：注册制框架，S-101/124/125 等产品族的底座</li></ul>
<p>下一篇预告：《S-57 数据解剖：把一个 ENC 文件拆给你看》——拿真实测试数据，从目录文件拆到单个要素。</p>
<hr>
<p>我是夜航海图，做海图与地图渲染开发的工程师。博客「航图笔记」同步更新全部文章，欢迎 RSS 订阅。你卡在哪一步，评论区聊聊。</p>]]></content:encoded>
  </item>
</channel></rss>