<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[半部电台：用 ESP32-C6 做一台“附近通信”旋钮小设备——XidianFlipper 代码阅读笔记]]></title><description><![CDATA[<blockquote>
<p dir="auto">项目地址：<a href="https://github.com/qilaotie/XidianFlipper" rel="nofollow ugc">qilaotie/XidianFlipper</a> ｜ 在线社区：<a href="https://qilaotie.github.io/XidianFlipper/" rel="nofollow ugc">半部小岛</a></p>
</blockquote>
<p dir="auto">"半部电台起家"是西电校史里最常被提起的一句话。1931 年，红军第一部电台只能收、不能发，于是被叫作"半部电台"。这个项目借了这个名字，但它并不是一台真的无线电——它是一台基于 <strong>ESP32-C6</strong> 的旋钮小设备：靠 BLE 广播在几十米内和同伴互发消息、一起玩桌游、养一只电子宠物，顺便还能当 PPT 遥控器和 Wi-Fi 扫描器。</p>
<p dir="auto">本文是我读这个仓库源码后的笔记，重点放在<strong>它是怎么搭起来的</strong>，以及那些只有在真机上才会踩到的坑。</p>
<hr />
<h2>一、硬件与工程基础</h2>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>项目</th>
<th>配置</th>
</tr>
</thead>
<tbody>
<tr>
<td>主控</td>
<td>ESP32-C6，4 MiB Flash</td>
</tr>
<tr>
<td>屏幕</td>
<td>128×32 SSD1306 OLED（I2C，地址 0x78）</td>
</tr>
<tr>
<td>交互</td>
<td>EC06 旋转编码器 + 独立按键 BACK</td>
</tr>
<tr>
<td>声光</td>
<td>MCPWM 蜂鸣器、RMT 驱动 WS2812</td>
</tr>
<tr>
<td>电源</td>
<td>GPIO4 集中管理负载开关 + ADC 电量检测</td>
</tr>
<tr>
<td>工具链</td>
<td>ESP-IDF 5.5.4，CMake 工程</td>
</tr>
</tbody>
</table>
<p dir="auto">工程按 <code>firmware/</code>（业务）与 <code>components/</code>（第三方与公共组件）两大块切分，业务目录再分为 <code>app / config / drivers / protocol / radio / system / ui</code>：</p>
<pre><code>firmware/
├── app/        # 消息、好友、Wi-Fi、无线工具、摩斯训练
├── config/     # constants.h / pins.h / defaults.h / log_config.h
├── drivers/    # ec06 编码器、rgb_led、rmt_ws2812、mcpwm_buzzer、adc_battery...
├── protocol/   # mesh_packet.c、mesh_crypto.c
├── radio/      # ble_radio.c、tool_hid.c
├── system/     # nvs_config、log_manager、power_mgmt、event_dispatcher
└── ui/         # menu_pages、local_pages、party_pages、pet_page、dev_mode...
components/
├── u8g2/                  # 图形库（含字模）
├── u8g2-hal-esp-idf/      # I2C bit-bang 回调
└── oled_mirror/           # 把屏幕画面经 USB CDC 投到电脑
</code></pre>
<h3>初始化顺序是这份代码里最值得抄的部分</h3>
<p dir="auto"><code>firmware_init_all()</code> 里每一步都有注释说明"为什么必须在这里"，这不是装饰，是血泪：</p>
<pre><code class="language-c">event_init();                 /* 1. 任何任务通信之前 */
load_switch_init();           /* 1.5. GPIO4 总电源，任何外设之前 */
log_manager_init();           /* 2.5. 事件之后、其他模块之前 */
buzzer_init();  haptic_init();
nvs_config_init();            /* 5. NVS 必须在 rgb_led 之前：
                                     否则 g_config 全零 → 亮度 0% */
rgb_init();  rgb_led_init();
ec06_init(&amp;ec06_cfg);         /* 7. 编码器 ISR + 任务 */
...
nvs_init();  ui_init();       /* OLED 提前亮，消除上电黑屏 */
u8g2_Setup_ssd1306_i2c_128x32_univision_f(...);
mesh_crypto_init();           /* 9. 加密必须在无线之前就绪 */
ble_radio_init(mesh_notify_dispatch, NULL);  /* 10. NimBLE 主机任务 */
quick_msg_init();  mesh_notify_init();
</code></pre>
<p dir="auto">几个细节：</p>
<ul>
<li><strong>负载开关最先初始化</strong>。整块外设供电挂在 GPIO4 上，晚一步就是莫名其妙的 I2C 失败。</li>
<li><strong>NVS 早于 RGB</strong>。配置从 Flash 读出来之前，结构体是零值，亮度按 0% 处理，用户看到的就是"灯不亮"。</li>
<li><strong>OLED 抢先于 BLE</strong>。NimBLE 初始化会占用一截时间，先把屏幕点亮，用户感知到的开机时间就短了。</li>
<li><strong>BLE 的致命错误有兜底</strong>：读 eFuse MAC 失败或设 BLE 地址失败时，红灯快闪 10 次、屏幕上画 <code>FATAL ERR 0x01/0x02</code>，然后死循环等看门狗。宁可停住，也不带着半残的协议栈跑。</li>
</ul>
<p dir="auto">冷启动还有一段"开机图 2 秒 + 黑屏 1 秒"的过渡，而从 DeepSleep 按键唤醒时会跳过这段直接进 UI——<code>g_wake_reason</code> 判断一下就行，成本极低，体验差别很大。</p>
<hr />
<h2>二、协议层：27 字节头 + 128 字节载荷</h2>
<p dir="auto"><code>firmware/protocol/mesh_packet.h</code> 定义了整套私有协议 v3，线格式非常克制：</p>
<pre><code class="language-c">#define MESH_MAGIC        0x4D45   /* 'M','E' */
#define MESH_VERSION      3
#define MESH_HEADER_SIZE  27
#define MESH_PAYLOAD_MAX  128
#define MESH_CRC_SIZE     2

typedef struct {
    uint16_t magic;
    uint8_t  version;
    uint8_t  src_mac[6], dst_mac[6];   /* 用 MAC 直接寻址 */
    uint8_t  type, len, flags, channel;/* channel: 1~16 */
    uint32_t session_id, msg_id;       /* 会话 + 消息序号，做回执去重 */
    uint8_t  payload[MESH_PAYLOAD_MAX];
    uint16_t crc16;
} mesh_packet_t;
</code></pre>
<p dir="auto">打包后最大 <code>27 + 128 + 2 = 157</code> 字节——刚好塞进 BLE 的传统广播/扩展广播里，不需要建连接。几个设计取舍：</p>
<ol>
<li><strong>用 MAC 当身份</strong>。<code>src_mac</code>/<code>dst_mac</code> 六字节，广播地址全 <code>FF</code>。好处是零配置、无路由表；代价是换设备就换身份，好友关系必须落 NVS（<code>namespace = "mesh_cfg"</code>）。</li>
<li><strong><code>flags</code> 一个字节塞了四件事</strong>：<code>ACK_REQUEST</code>、<code>ROUTED</code>、<code>ANNOUNCEMENT</code>、<code>CUSTODY</code>，再加两位跳数（<code>MESH_HOP_MASK 0x0c</code>，<code>MESH_TOTAL_HOPS_MAX 3</code>）。协议从"能通"进化到"能转发、能代存"，头长度一字节没变。</li>
<li><strong><code>session_id</code> + <code>msg_id</code> 撑起了回执与重试</strong>。发送状态机（已发 / 已收 / 超时）全靠这两个字段，<code>MESH_TYPE_RECEIPT</code> 就是回执报文。</li>
<li><strong>18 种消息类型</strong>共享同一条管线：快捷消息、摩斯、好友请求、信标、回执、石头剪刀布、寻宝、反应赛、数字炸弹、宠物、协作、社交……加新玩法基本只是往枚举里加一个 <code>MESH_TYPE_*</code>。</li>
</ol>
<p dir="auto">最值得称赞的是头文件第一行注释：</p>
<blockquote>
<p dir="auto"><code>/* BLE local messaging v3. Wire integers are little endian; CRC is not authentication. */</code></p>
</blockquote>
<p dir="auto"><strong>CRC 不是认证。</strong> 这句话省掉了很多误解：这不是加密私聊，抓包就能读；游戏里也没有密码学防作弊。项目在 README、功能说明、工具说明里反复重申这一点——工程文档里肯把边界写清楚，比多堆十个功能更难得。</p>
<hr />
<h2>三、通信载体：广播式"对讲"，以及功耗的账</h2>
<p dir="auto">设备之间不建 BLE 连接，全靠扫描广播收发。<code>constants.h</code> 里这几个数字决定了续航：</p>
<pre><code class="language-c">#define SCAN_PERIOD_MS   5000   /* 扫描周期 5s */
#define SCAN_WINDOW_MS   100    /* 扫描窗口 100ms → 2% 占空比 */
#define COVER_ADV_MS     6000   /* 覆盖广播 6s ≥ 扫描周期 5s */
#define BLE_ADV_INTERVAL_MIN_MS 100
#define BLE_ADV_INTERVAL_MAX_MS 300
</code></pre>
<ul>
<li>扫描占空比只有 <strong>2%</strong>，所以平时几乎不耗电；代价是延迟——最坏要等一个扫描周期才收到消息。</li>
<li><strong><code>COVER_ADV_MS &gt; SCAN_PERIOD_MS</code></strong> 这条不等式是整个"对讲感"的关键：发送方要保证自己广播的持续时间覆盖住对方一整个扫描周期，否则就会出现"我明明发了对方没收到"的经典 bug。</li>
<li>DeepSleep 周期 5 秒，冷启动后留 <strong>15 秒宽限期</strong>禁止休眠——不然 USB 还没枚举完设备就睡了，串口工具上永远看不到设备。</li>
</ul>
<p dir="auto">这套机制的能力边界也因此很明确：<strong>不需要路由器、SIM 卡或手机，但它也不是运营商短信，不是标准 Bluetooth Mesh</strong>。三跳中继是<strong>实验特性</strong>，默认关闭、重启不保留，要三台同版设备才能实测 A→B→C。</p>
<hr />
<h2>四、界面：128×32 像素怎么装下一个系统</h2>
<p dir="auto">屏幕只有 128×32，四行文字。项目直接移植了 <strong>WouoUI</strong> 的菜单框架（<code>ui.layer / ui.state / ui.select[] / ui.fade</code>），配合 u8g2 渲染。</p>
<p dir="auto">在这个尺寸上做 60 多个功能入口，靠的是三件事：</p>
<ul>
<li><strong>分层菜单</strong>：主菜单按"消息 → 摩斯电台 → 学习 → 好友 → 趣玩 → 工具 → 设置 → 关于"排列，长描述走滚动。</li>
<li><strong>三套皮肤</strong>：经典像素、极简线框、终端，设置里切换、重启保留。</li>
<li><strong>中英双语开关</strong>：主菜单、设置、工具、趣玩全部双语，即时生效。中文用的是<strong>精简字库</strong>——128×32 的屏幕上，全量 CJK 点阵字模对 4 MiB Flash 来说并不划算。</li>
</ul>
<h3>一个小而妙的组件：OLED Mirror</h3>
<p dir="auto"><code>components/oled_mirror</code> 把每一帧画面通过 USB Serial/JTAG 发给电脑：</p>
<pre><code class="language-c">usb_serial_jtag_driver_config_t usj_cfg = {
    .tx_buffer_size = 4096,   /* 整帧/日志/控制帧不被截断 */
    .rx_buffer_size = 256,
};
oled_mirror_begin(&amp;u8g2, usb_cdc_send_callback);
</code></pre>
<p dir="auto">一帧 <code>128×32/8 = 512</code> 字节点阵，加 5 字节帧头正好 517 字节，所以 TX buffer 必须给到 4096 且写入超时只有 10 ms（USB 没插时静默丢弃，绝不阻塞渲染）。更妙的是它顺手 <code>esp_log_set_vprintf()</code> 接管了日志，让 ESP_LOGI 和画面走同一套帧格式——<strong>演示、答辩、录屏时不用举着放大镜看 128×32 的小屏</strong>。这个组件还单独做了 <code>ENABLE_OLED_MIRROR</code> 编译开关，不影响正式构建。</p>
<hr />
<h2>五、功能盘点到什么程度</h2>
<p dir="auto">不逐条罗列，只看"密度"：</p>
<ul>
<li><strong>MESSAGES</strong>：快捷短语 10 条（单条上限 48 字符，见 <code>QUICK_MESSAGE_MAX_LEN</code>）、机身 ASCII 编辑、字符表情、六槽持久草稿箱、频道（1–16）、好友定向、收件箱与发送状态。</li>
<li><strong>摩斯</strong>：实时收发、自由练习、听音选字、跟练、听写。所有时序阈值都随 <strong>WPM 动态计算</strong>（<code>morse_engine_set_wpm()</code>），只保留固定值：20 ms 消抖、±30% 教学容差、600–900 ms 字母间隔、1500 ms 空闲自动发送、5000 ms TX 回 RX。</li>
<li><strong>FUN 14 个入口</strong>：宠物、竖屏俄罗斯方块、贪吃蛇、答案之书、骰子、花旗骰、附近石头剪刀布、寻宝雷达、反应赛、数字炸弹、1–10 秒盲计时、玩法说明、灯光蜂鸣合奏、旅行护照。</li>
<li><strong>多人桌游 10 款</strong>：UNO、谁是卧底、达芬奇密码、骗子酒馆、情书、快艇骰子、阿瓦隆、一夜狼、狼人杀、井字棋。统一"创建 → 图形准备房间 → 开局进牌桌 → 结算回原房间重新准备"的流程，还支持多桌大厅、只读观战、邀请。</li>
<li><strong>宠物</strong>：四属性（饱食/心情/精力/清洁）、四阶段成长、三成年分支、七个内部小游戏、金币商店、背包装饰图鉴、双机拜访与对战、房间皮肤。<strong>没有死亡设定</strong>，喂食清洁睡觉全免费。</li>
<li><strong>TOOLS 11 项</strong>：PPT/媒体/拍照 HID 遥控、Wi-Fi 连接、云同步、Wi-Fi 扫描、BLE/RSSI 扫描、内存状态、实验性自组网、网络诊断、队伍与社交。</li>
</ul>
<p dir="auto">注意 HID 遥控期间电台通信会暂停——BLE 主机和外设角色抢同一套射频资源，这是硬约束，写在了手册里。</p>
<hr />
<h2>六、最硬的一仗：分区迁移</h2>
<p dir="auto">这是整个项目里我最想单独讲的部分，因为它是<strong>不可逆操作</strong>。</p>
<p dir="auto">原版固件的分区是老三槽布局，单个应用槽只有 <code>1,245,184</code> 字节。应用从 <code>768,016</code> 字节一路长到 <code>1,527,808</code>（当前发布包 <code>1,739,152</code>），三槽布局再也装不下：</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>指标</th>
<th style="text-align:right">冻结原版</th>
<th style="text-align:right">迁移后</th>
</tr>
</thead>
<tbody>
<tr>
<td>单应用槽</td>
<td style="text-align:right">1,245,184 B</td>
<td style="text-align:right">2,097,152 B（2 MiB）</td>
</tr>
<tr>
<td>应用镜像</td>
<td style="text-align:right">768,016 B</td>
<td style="text-align:right">1,739,152 B</td>
</tr>
<tr>
<td>分区方案</td>
<td style="text-align:right">旧三槽 + OTA</td>
<td style="text-align:right"><code>factory-2m-usb-v1</code>，<strong>取消两个 OTA 槽</strong></td>
</tr>
<tr>
<td>更新方式</td>
<td style="text-align:right">OTA / USB</td>
<td style="text-align:right"><strong>仅 USB</strong></td>
</tr>
</tbody>
</table>
<p dir="auto">关键约束是<strong>两处 NVS 的地址和大小绝对不能动</strong>：</p>
<ul>
<li><code>nvs</code>：<code>0x9000</code> / <code>0x6000</code></li>
<li><code>nvs_sec</code>：<code>0x3b0000</code> / <code>0x6000</code></li>
</ul>
<p dir="auto">因为它们存着 NVS v7 配置、storage ABI 1 的 UI 参数、宠物数据、游戏最高分和好友列表。工程里 <code>docs/partition_migration.md</code> 和 <code>AGENTS.md</code> 把流程钉死成几条：<strong>迁移前必须双读校验整片 Flash 备份 → 迁移工具必须验证设备身份、旧布局和备份 → 不能只刷分区表 → 完成后整片回读确认未授权区域没被写</strong>。</p>
<p dir="auto">结果也记录得很实在：COM9、COM10、COM15 三台设备分别完成双读整片备份、2 MiB 迁移和完整回读，确认 bootloader 未替换（COM15 因旧 bootloader 不同另行批准更新）、两处 NVS 及其他未授权区域不变。</p>
<p dir="auto"><strong>代价换取</strong>：没有 OTA 了。这很痛，但 <code>factory 0x20000 / 0x200000</code> 的单槽方案让应用可用空间从 1.2 MiB 涨到 2 MiB，功能密度才成为可能。工程在 README 里明确写了"迁移后仅支持 USB 更新，不支持 OTA"，没有含糊。</p>
<hr />
<h2>七、工程习惯：把"没验证"写进文档</h2>
<p dir="auto">这份仓库最不像"学生作业"的地方，是它对自己的验收口径极其苛刻：</p>
<ul>
<li><strong>构建产物带指纹</strong>：输出到 <code>build/release/</code>，包含 <code>manifest.json</code>、镜像、源码记录、<code>validation/build.log</code> 和 SHA-256，<code>python tools/verify.py</code> 独立核验。README 直接给应用哈希 <code>21c39ff9...a916f3</code>，不靠"我记得我编过"。</li>
<li><strong>模拟与实机分开报告</strong>：<code>AGENTS.md</code> 里那句"宿主模拟测试和实机验收分开报告；不得把模拟帧率、断电或回滚结果表述为实机通过"，是很多项目缺的一条纪律。发布清单里 <code>hardware_tested: false</code> 一直保留着，哪怕已经上机测过一部分。</li>
<li><strong>能力边界逐条列出</strong>：RSSI 只表示信号冷热、不给方向距离；拿到 IPv4 不等于能上网；<code>KEY SENT</code> 不等于真的拍了照；云分享关闭不等于撤下，必须执行 <code>REMOVE ONLINE</code>。</li>
<li><strong>版本号不自作主张</strong>：扩展版固件字符串仍是 <code>1.0.0-noautosleep</code>，靠发行清单和 SHA-256 区分构建，明确"不把本扩展版误称为 V1.5"。</li>
</ul>
<p dir="auto">顺带一个工程上的小坑：<code>components/u8g2/csrc/u8g2_fonts.c</code> 有 <strong>40 MB</strong> 源码，几万个字模。ESP-IDF 默认 <code>-ffunction-sections -fdata-sections</code> + 链接期 <code>--gc-sections</code> 会丢掉没引用的段，所以最终镜像不会爆炸，但<strong>编译时间和仓库体积是真的贵</strong>——这也是它把完整 u8g2 直接 vendor 进来要付的账。</p>
<hr />
<h2>八、它不是什么</h2>
<p dir="auto">作者写得很清楚，我照抄一遍，因为这几句比功能列表更有价值：</p>
<ul>
<li>没有 IR、NFC/RFID、Sub-GHz，<strong>不是完整 Flipper Zero 替代品</strong>；</li>
<li>消息<strong>不是加密私聊</strong>，游戏<strong>没有密码学防作弊</strong>；</li>
<li>Wi-Fi 凭据<strong>未启用 NVS 加密</strong>，不要输入真实敏感信息；</li>
<li>不是运营商短信，不是标准 Bluetooth Mesh，也不是互联网电台；</li>
<li>云排行榜是<strong>设备自报</strong>的，不是防作弊比赛。</li>
</ul>
<p dir="auto">一台 4 MiB Flash、128×32 屏幕、靠一颗旋钮操作的小设备，做到"附近能聊、能一起玩十款桌游、能养宠物、能当遥控器"，靠的不是堆料，而是<strong>把每一字节的协议头、每一次扫描占空比、每一段 NVS 地址都算清楚</strong>。</p>
<hr />
<h2>九、可以带走的三条经验</h2>
<ol>
<li><strong>初始化顺序就是需求</strong>。事件系统、电源开关、NVS、屏幕、协议栈，谁在谁前面都有理由；写成注释，下一个人（或三个月后的你）会感谢自己。</li>
<li><strong>协议头要预留成长空间</strong>。v3 的 27 字节头从"点对点消息"长到"三跳中继 + 代存 + 回执"，头长度一次没变，全靠 <code>flags</code> 里的两位跳数和四种标记。前期省一点，后期就能改。</li>
<li><strong>不可逆操作要有备份和验收清单</strong>。分区迁移那套"双读备份 → 验证身份/旧布局 → 只改授权区域 → 整片回读比对"，比任何功能都更像一个真正的工程项目。</li>
</ol>
<p dir="auto">如果对"半部电台"感兴趣，仓库里的 <code>docs/</code> 有完整手册：功能简介、使用手册、宠物手册、桌游适配、分区迁移、发布与构建。也可以去<a href="https://qilaotie.github.io/XidianFlipper/" rel="nofollow ugc">半部小岛</a>看看设备自报的排行榜和宠物展示柜。</p>
<blockquote>
<p dir="auto">半部电台起家，靠的从来不是设备多好，而是有人愿意把能收的那一半，慢慢做成能发的那一半。</p>
</blockquote>
]]></description><link>http://106.52.42.217:6080/topic/11/半部电台-用-esp32-c6-做一台-附近通信-旋钮小设备-xidianflipper-代码阅读笔记</link><generator>RSS for Node</generator><lastBuildDate>Thu, 05 Nov 2026 17:48:16 GMT</lastBuildDate><atom:link href="http://106.52.42.217:6080/topic/11.rss" rel="self" type="application/rss+xml"/><pubDate>Tue, 03 Nov 2026 14:36:09 GMT</pubDate><ttl>60</ttl></channel></rss>