Browse Source

添加 ASCII 码转换工具及相关功能,更新协议文档和界面

avery 2 days ago
parent
commit
041041748b

+ 16 - 0
app.wxss

@@ -2189,6 +2189,14 @@ page {
   color: var(--danger);
 }
 
+.ascii-code-input {
+  width: 360rpx;
+}
+
+.ascii-code-result-value {
+  max-width: 390rpx;
+}
+
 .crc-calc-result-row {
   align-items: stretch;
   min-height: 0;
@@ -2443,6 +2451,14 @@ page {
     font-size: 26rpx;
   }
 
+  .ascii-code-input {
+    width: 260rpx;
+  }
+
+  .ascii-code-result-value {
+    max-width: 260rpx;
+  }
+
   .crc-calc-result-value {
     width: 100%;
     max-width: none;

+ 4 - 2
features/bootloader/firmware.js

@@ -9,13 +9,15 @@ const FILE_SIZES = {
 const FLASH_LAYOUTS = {
   16: {
     capacity: FILE_SIZES[16],
+    protectedStartAddress: 0x3F80,
     startAddress: 0x0400,
-    endAddress: 0x4000
+    endAddress: 0x3F80
   },
   32: {
     capacity: FILE_SIZES[32],
+    protectedStartAddress: 0x7F00,
     startAddress: 0x0800,
-    endAddress: 0x8000
+    endAddress: 0x7F00
   }
 }
 const FLASH_SIZE_TEXT = Object.keys(FILE_SIZES)

+ 13 - 4
features/bootloader/service.js

@@ -12,6 +12,7 @@ const {
   formatBootloaderCrc,
   toHex
 } = require('../../protocols/bootloader/index.js')
+const storageAccessProtocol = require('../../protocols/storage-access/index.js')
 const {
   delay
 } = require('../../utils/base-utils.js')
@@ -25,9 +26,9 @@ const {
 const firmware = require('./firmware.js')
 const bootloaderTransport = require('./transport.js')
 
-const HANDSHAKE_INTERVAL_MS = 200
-const HANDSHAKE_ATTEMPTS = 10
-const HANDSHAKE_TIMEOUT_MS = HANDSHAKE_INTERVAL_MS * HANDSHAKE_ATTEMPTS
+const HANDSHAKE_TIMEOUT_MS = 500
+const HANDSHAKE_INTERVAL_MS = 50
+const HANDSHAKE_ATTEMPTS = Math.ceil(HANDSHAKE_TIMEOUT_MS / HANDSHAKE_INTERVAL_MS)
 const PROGRAM_RESPONSE_TIMEOUT_MS = 6000
 
 const state = {
@@ -121,6 +122,13 @@ function getHandshakeDetail(response) {
   return `${response.versionText || '--'} / ${response.chipIdText || '--'}`
 }
 
+async function resetChipForUpgrade() {
+  const resetFrame = storageAccessProtocol.buildControlFrame(storageAccessProtocol.CONTROL_OP.RESET)
+  const sent = await bootloaderTransport.sendRawFrame(resetFrame, '存储访问复位')
+
+  if (!sent) throw new Error('存储访问复位帧发送失败')
+}
+
 async function sendHandshakeKeepAlive() {
   if (state.isBootloaderBusy) return false
 
@@ -290,11 +298,12 @@ async function startUpgrade() {
   setState({
     bootloaderDetailText: '',
     bootloaderProgress: 0,
-    bootloaderStatusText: '握手中',
+    bootloaderStatusText: '复位中',
     isBootloaderBusy: true
   })
 
   try {
+    await resetChipForUpgrade()
     await handshakeUntilReady()
 
     setState({

+ 1 - 0
features/settings/protocol-implementation.js

@@ -16,6 +16,7 @@ const GUIDE = {
     { id: 'info', text: 'bit6=1 时 bit0~bit5 表示特殊指令码;当前仅定义 0x01 复位,因此复位帧 CMD=0x41。' },
     { id: 'control', text: 'CodeInfo 同步先发送 00+CRC 读取 area=0x00 描述符,数据区返回 TLV 起始 addr32、len16、地址位宽和最大包长。' },
     { id: 'struct', text: 'CodeInfo 使用 TYPE + LEN8 + VALUE 的纯 TLV 信息块;0x01~0x08 为固定内存入口,VALUE 为 addr(2/4)+len16+name_len8+name;单独变量由 TLV 给长度,UI 或 enum 导入配置解释类型。' },
+    { id: 'bootloader', text: 'Bootloader 升级前先发送存储访问复位帧 CMD=0x41,再在 500ms 内每 50ms 发送一次 Bootloader 握手帧。' },
     { id: 'source', text: '协议实现源码已暂时移除,设置页仅保留文件占位与说明,避免给出过期从机实现。' }
   ]
 }

+ 21 - 0
features/tools/handlers/ascii-code.js

@@ -0,0 +1,21 @@
+const asciiCodeTool = require('../../../tools/ascii-code/index.js')
+
+const handlers = {
+  setAsciiCodeState(changedData) {
+    this.setData(asciiCodeTool.updateState(this.data, changedData))
+  },
+
+  onAsciiCodeInput(event) {
+    this.setAsciiCodeState({
+      asciiCodeInputText: event.detail.value
+    })
+  },
+
+  clearAsciiCodeInput() {
+    this.setData(asciiCodeTool.clearInput(this.data))
+  }
+}
+
+module.exports = {
+  handlers
+}

+ 5 - 0
features/tools/index.js

@@ -1,4 +1,5 @@
 const crcTool = require('../../tools/crc-hash/crc-tool.js')
+const asciiCodeTool = require('../../tools/ascii-code/index.js')
 const filterCalculator = require('../../tools/filter/index.js')
 const smdCodeCalculator = require('../../tools/smd-code/index.js')
 const refrigerationCalculator = require('../../tools/refrigeration/index.js')
@@ -9,6 +10,7 @@ const {
 } = require('../../utils/base-utils.js')
 
 const crcHandlers = require('./handlers/crc.js')
+const asciiCodeHandlers = require('./handlers/ascii-code.js')
 const filterHandlers = require('./handlers/filter.js')
 const reactanceHandlers = require('./handlers/reactance.js')
 const refrigerationHandlers = require('./handlers/refrigeration.js')
@@ -18,6 +20,7 @@ const threePhasePowerHandlers = require('./handlers/three-phase-power.js')
 const TOOL_ENTRIES = [
   { view: 'bootloader', label: 'BootLoader升级', icon: 'icon-chip', iconSrc: '/assets/icons/chip-white.png' },
   { view: 'crc', label: 'CRC与哈希计算', icon: 'icon-crc', iconSrc: '/assets/icons/hash-white.png' },
+  { view: 'asciiCode', label: 'ASCII/数值转换', icon: 'icon-terminal', iconSrc: '/assets/icons/terminal-white.png' },
   { view: 'filter', label: '滤波器计算', icon: 'icon-filter', iconSrc: '/assets/icons/funnel-white.png' },
   { view: 'reactance', label: '电抗计算', icon: 'icon-reactance', iconSrc: '/assets/icons/audio-waveform-white.png' },
   { view: 'smdCode', label: '贴片电阻/容代码', icon: 'icon-smd', iconSrc: '/assets/icons/microchip-white.png' },
@@ -45,6 +48,7 @@ function getToolTitle(view) {
 function createToolInitialState() {
   return {
     ...crcTool.createInitialState(),
+    ...asciiCodeTool.createInitialState(),
     ...filterCalculator.createInitialState(),
     ...smdCodeCalculator.createInitialState(),
     ...refrigerationCalculator.createInitialState(),
@@ -86,6 +90,7 @@ const toolPageHandlers = {
     })
   },
   ...crcHandlers.handlers,
+  ...asciiCodeHandlers.handlers,
   ...filterHandlers.handlers,
   ...reactanceHandlers.handlers,
   ...smdCodeHandlers.handlers,

+ 43 - 0
pages/settings/settings.wxml

@@ -340,6 +340,49 @@
       </view>
     </block>
 
+    <block wx:elif="{{activeSettingsView == 'asciiCode'}}">
+      <view class="panel params-section-panel">
+        <view class="params-section-title smd-section-title">
+          <view class="smd-section-title-text">输入</view>
+          <view wx:if="{{asciiCodeInputText}}" class="panel-action-button" bindtap="clearAsciiCodeInput">清空</view>
+        </view>
+        <view class="param-row input-row">
+          <view class="param-main">
+            <view class="param-name">ASCII / 数值</view>
+          </view>
+          <view class="input-wrap">
+            <input
+              class="value-input smd-code-input ascii-code-input"
+              type="text"
+              placeholder="A / 0x41 / 65"
+              value="{{asciiCodeInputText}}"
+              bindinput="onAsciiCodeInput"
+            />
+          </view>
+        </view>
+        <view wx:if="{{asciiCodeErrorText}}" class="filter-error-inline">{{asciiCodeErrorText}}</view>
+      </view>
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title">结果</view>
+        <view
+          wx:for="{{asciiCodeResultRows}}"
+          wx:for-item="row"
+          wx:key="label"
+          class="param-row smd-result-row"
+        >
+          <view class="param-main">
+            <view class="param-name">{{row.label}}</view>
+          </view>
+          <view
+            class="smd-result-value ascii-code-result-value"
+            data-value="{{row.copyValue || row.value}}"
+            bindtap="copyToolResult"
+          >{{row.value}}</view>
+        </view>
+      </view>
+    </block>
+
     <block wx:elif="{{activeSettingsView == 'filter'}}">
       <view class="panel params-section-panel">
         <view class="params-section-title filter-section-title">

+ 1 - 1
protocols/bootloader/index.js

@@ -164,7 +164,7 @@ function parseBootloaderResponse(bytes, kind) {
       throw new Error('全 Flash 校验反馈帧不匹配')
     }
 
-    const flashCrc = ((bytes[5] & 0xFF) << 8) | (bytes[6] & 0xFF)
+    const flashCrc = (bytes[5] & 0xFF) | ((bytes[6] & 0xFF) << 8)
 
     return {
       flashCrc,

+ 220 - 0
tools/ascii-code/index.js

@@ -0,0 +1,220 @@
+const BYTE_MIN = 0
+const BYTE_MAX = 0xFF
+
+const CONTROL_LABELS = {
+  0x00: 'NUL',
+  0x01: 'SOH',
+  0x02: 'STX',
+  0x03: 'ETX',
+  0x04: 'EOT',
+  0x05: 'ENQ',
+  0x06: 'ACK',
+  0x07: 'BEL',
+  0x08: 'BS',
+  0x09: '\\t',
+  0x0A: '\\n',
+  0x0B: 'VT',
+  0x0C: '\\f',
+  0x0D: '\\r',
+  0x0E: 'SO',
+  0x0F: 'SI',
+  0x10: 'DLE',
+  0x11: 'DC1',
+  0x12: 'DC2',
+  0x13: 'DC3',
+  0x14: 'DC4',
+  0x15: 'NAK',
+  0x16: 'SYN',
+  0x17: 'ETB',
+  0x18: 'CAN',
+  0x19: 'EM',
+  0x1A: 'SUB',
+  0x1B: 'ESC',
+  0x1C: 'FS',
+  0x1D: 'GS',
+  0x1E: 'RS',
+  0x1F: 'US',
+  0x20: 'SP',
+  0x7F: 'DEL'
+}
+
+function formatHexByte(value) {
+  return `0x${(Number(value) & BYTE_MAX).toString(16).toUpperCase().padStart(2, '0')}`
+}
+
+function getDefaultRows() {
+  return [
+    { label: '字符', meta: '', value: '--', copyValue: '' },
+    { label: 'HEX', meta: '', value: '--', copyValue: '' },
+    { label: 'DEC', meta: '', value: '--', copyValue: '' }
+  ]
+}
+
+function createEmptyResultState() {
+  return {
+    asciiCodeErrorText: '',
+    asciiCodeResultRows: getDefaultRows()
+  }
+}
+
+function splitValueTokens(text) {
+  return String(text || '')
+    .trim()
+    .split(/[\s,;,;]+/)
+    .map((token) => token.trim())
+    .filter(Boolean)
+}
+
+function tokenLooksNumeric(token) {
+  return /^0x/i.test(token) || /^\d+$/.test(token)
+}
+
+function tokenIsValidNumeric(token) {
+  return /^0x[0-9a-f]+$/i.test(token) || /^\d+$/.test(token)
+}
+
+function shouldParseAsNumeric(text) {
+  const tokens = splitValueTokens(text)
+  if (!tokens.length) return false
+
+  return tokens.every(tokenLooksNumeric)
+}
+
+function parseNumericToken(token) {
+  if (!tokenIsValidNumeric(token)) {
+    throw new Error('数值格式无效')
+  }
+
+  const value = /^0x/i.test(token)
+    ? parseInt(token.slice(2), 16)
+    : Number(token)
+
+  if (!Number.isInteger(value) || value < BYTE_MIN || value > BYTE_MAX) {
+    throw new Error('数值范围需为 0 - 255')
+  }
+
+  return value
+}
+
+function parseNumericBytes(text) {
+  return splitValueTokens(text).map(parseNumericToken)
+}
+
+function parseTextBytes(text) {
+  const bytes = []
+
+  Array.from(String(text || '')).forEach((char) => {
+    const value = char.codePointAt(0)
+    if (!Number.isInteger(value) || value < BYTE_MIN || value > BYTE_MAX) {
+      throw new Error('字符需在 0 - 255 范围内')
+    }
+    bytes.push(value)
+  })
+
+  return bytes
+}
+
+function formatChar(value) {
+  const byte = Number(value) & BYTE_MAX
+  if (Object.prototype.hasOwnProperty.call(CONTROL_LABELS, byte)) return CONTROL_LABELS[byte]
+  if (byte >= 0x21 && byte <= 0x7E) return String.fromCharCode(byte)
+
+  return `\\x${byte.toString(16).toUpperCase().padStart(2, '0')}`
+}
+
+function formatDisplayText(bytes = []) {
+  if (!bytes.length) return '--'
+  if (bytes.every((byte) => {
+    const value = Number(byte) & BYTE_MAX
+    return value >= 0x21 && value <= 0x7E
+  })) {
+    return bytes.map((byte) => String.fromCharCode(Number(byte) & BYTE_MAX)).join('')
+  }
+
+  return bytes.map(formatChar).join(' ')
+}
+
+function bytesToText(bytes = []) {
+  return bytes.map((byte) => String.fromCharCode(Number(byte) & BYTE_MAX)).join('')
+}
+
+function createResultRows(bytes = []) {
+  if (!bytes.length) return getDefaultRows()
+
+  const hexText = bytes.map(formatHexByte).join(' ')
+  const decText = bytes.map((byte) => String(Number(byte) & BYTE_MAX)).join(' ')
+  const charText = formatDisplayText(bytes)
+
+  return [
+    {
+      copyValue: bytesToText(bytes),
+      label: '字符',
+      value: charText
+    },
+    {
+      copyValue: hexText,
+      label: 'HEX',
+      value: hexText
+    },
+    {
+      copyValue: decText,
+      label: 'DEC',
+      value: decText
+    }
+  ]
+}
+
+function buildState(source = {}) {
+  const inputText = String(source.asciiCodeInputText || '')
+  const trimmedText = inputText.trim()
+
+  if (!trimmedText) {
+    return {
+      asciiCodeInputText: inputText,
+      ...createEmptyResultState()
+    }
+  }
+
+  try {
+    const numericMode = shouldParseAsNumeric(trimmedText)
+    const bytes = numericMode ? parseNumericBytes(trimmedText) : parseTextBytes(inputText)
+
+    return {
+      asciiCodeErrorText: '',
+      asciiCodeInputText: inputText,
+      asciiCodeResultRows: createResultRows(bytes)
+    }
+  } catch (error) {
+    return {
+      asciiCodeErrorText: error && error.message ? error.message : '转换失败',
+      asciiCodeInputText: inputText,
+      asciiCodeResultRows: getDefaultRows()
+    }
+  }
+}
+
+function createInitialState() {
+  return buildState({
+    asciiCodeInputText: ''
+  })
+}
+
+function updateState(state, changedData = {}) {
+  return buildState({
+    ...state,
+    ...changedData
+  })
+}
+
+function clearInput(state = {}) {
+  return updateState(state, {
+    asciiCodeInputText: ''
+  })
+}
+
+module.exports = {
+  clearInput,
+  createInitialState,
+  formatHexByte,
+  updateState
+}

+ 5 - 5
协议架构说明.md

@@ -117,10 +117,10 @@ BLE 透传链路
 
 职责:
 
-- 保持独立升级协议,不依赖标准 Modbus 或存储访问协议
-- `protocols/bootloader/index.js` 负责 Bootloader 帧构建、CRC、ACK/NAK 和响应解析。
-- `features/bootloader/service.js` 负责设置页升级状态、固件加载、握手、擦除、编程、校验流程。
-- `features/bootloader/firmware.js` 负责芯片型号识别、Flash 容量推断、固件大小校验和升级地址布局。
+- Bootloader 升级帧保持独立;升级启动前先发送存储访问复位特殊指令,再进入 Bootloader 握手
+- `protocols/bootloader/index.js` 负责 Bootloader 帧构建、CRC、ACK/NAK 和响应解析;全 Flash 校验回帧的数据部分按低字节在前解析
+- `features/bootloader/service.js` 负责设置页升级状态、固件加载、升级前复位、500ms/50ms 握手轮询、擦除、编程、校验流程。
+- `features/bootloader/firmware.js` 负责芯片型号识别、Flash 容量推断、固件大小校验和升级地址布局;16K 的 `0x3F80..0x3FFF`、32K 的 `0x7F00..0x7FFF` 为不可擦写保留区
 - `features/bootloader/transport.js` 负责 Bootloader 原始帧发送、响应等待、断连中止和超时处理。
 
 ## 5. 领域模型
@@ -235,7 +235,7 @@ CodeInfo 读取只从同步流程进入:先读取 `area=0x00 CODEINFO` 描述
 
 - 设置页协议模式、主题、蓝牙状态、Bootloader 状态和工具页面状态聚合。
 - `protocol-implementation.js` 提供存储访问协议实现说明卡片和文件占位;从机源码暂未提供。
-- `features/tools/index.js` 聚合工具入口、工具导航、CRC/哈希、滤波、阻抗、贴片码、制冷、三相功率等工具状态和事件处理器。
+- `features/tools/index.js` 聚合工具入口、工具导航、CRC/哈希、ASCII/数值转换、滤波、阻抗、贴片码、制冷、三相功率等工具状态和事件处理器。
 - 工具内部按工具域拆分,避免设置页脚本膨胀。
 
 ## 7. 页面职责

+ 8 - 8
存储访问协议.md

@@ -261,10 +261,10 @@ TYPE LEN VALUE...
 |---|---:|---|
 | `byte_addr` | 2 | 结构体实例或单独变量所在区域的字节地址 |
 | `byte_len` | 2 | 结构体实例或单独变量的字节长度 |
-| `name_len` | 1 | 名称字节长度,`0..255`;实际不能超过本 TLV `LEN` 剩余字节 |
+| `name_len` | 1 | `name` 字节长度 |
 | `name` | `name_len` | 动态名称字段,UTF-8 或 ASCII;结构体定义名、变量名或 enum 类型名 |
 
-因此 16 位地址固定内存入口 TLV 的 `LEN = 0x05 + name_len`。
+因此 16 位地址固定内存入口 TLV 的 `LEN = 0x05 + name_len`,受 TLV 单项长度限制,`name_len` 最大为 250
 
 32 位地址入口 `VALUE`:
 
@@ -272,10 +272,10 @@ TYPE LEN VALUE...
 |---|---:|---|
 | `byte_addr` | 4 | 结构体实例或单独变量所在统一地址空间内的字节地址 |
 | `byte_len` | 2 | 结构体实例或单独变量的字节长度 |
-| `name_len` | 1 | 名称字节长度,`0..255`;实际不能超过本 TLV `LEN` 剩余字节 |
+| `name_len` | 1 | `name` 字节长度 |
 | `name` | `name_len` | 动态名称字段,UTF-8 或 ASCII;结构体定义名、变量名或 enum 类型名 |
 
-因此 32 位地址固定内存入口 TLV 的 `LEN = 0x07 + name_len`。
+因此 32 位地址固定内存入口 TLV 的 `LEN = 0x07 + name_len`,受 TLV 单项长度限制,`name_len` 最大为 248
 
 ### 9.2 自定义 TLV
 
@@ -355,8 +355,8 @@ MAX_PACKET = 0x0040
 
 ```text
 TYPE = 0x05
-LEN = 0x24
-VALUE = 20 00 00 40 4D 6F 74 6F 72 5F 52 75 6E 74 69 6D 65 5F 74 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
+LEN = 0x14
+VALUE = 20 00 00 40 0F 4D 6F 74 6F 72 5F 52 75 6E 74 69 6D 65 5F 74
 含义 = XDATA 0x2000 / 64 bytes / Motor_Runtime_t
 ```
 
@@ -364,8 +364,8 @@ VALUE = 20 00 00 40 4D 6F 74 6F 72 5F 52 75 6E 74 69 6D 65 5F 74 00 00 00 00 00
 
 ```text
 TYPE = 0x08
-LEN = 0x26
-VALUE = 00 00 21 00 00 02 73 70 65 65 64 5F 72 65 66 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
+LEN = 0x10
+VALUE = 00 00 21 00 00 02 09 73 70 65 65 64 5F 72 65 66
 含义 = ADDR32 0x00002100 / 2 bytes / speed_ref
 ```
 

+ 7 - 5
完整协议说明.md

@@ -9,7 +9,7 @@
 | 无协议 | `none` | 串口发送卡片、日志卡片 | 不显示协议参数组 | 只做原始字节透传和日志观察 |
 | 标准 Modbus | `modbus-rtu` | 标准 Modbus 指令卡片、日志卡片 | Modbus 寄存器组 | 使用从机地址、功能码和 Modbus CRC |
 | 存储访问 | `storage-access` | 同步、CodeInfo、特殊指令、读写卡片、日志卡片 | 存储访问结构体组/单变量组 | 按字节访问 DATA、IDATA、XDATA、CODE 或 32 位统一地址空间 |
-| Bootloader | 设置页升级工具 | Bootloader 工具卡片 | 不参与参数页 | 独立升级协议,不和 Modbus/存储访问混用 |
+| Bootloader | 设置页升级工具 | Bootloader 工具卡片 | 不参与参数页 | 升级前先用存储访问复位特殊指令进入 Bootloader,随后使用独立升级帧 |
 
 切换协议时,参数页会同步切换对应协议的分组集合。自动读取运行中如果检测到协议变化,会停止当前读取循环,避免用旧协议继续访问新模式下的参数组。
 
@@ -177,7 +177,7 @@ TYPE LEN VALUE...
 | `0x20..0x3F` | 自定义 TLV | 上位机保留原始项,当前跳过业务解析 |
 | `0x40..` | 板卡参数 | `0x40` 起按 `cave_freq/ref_volt/...` 递增 |
 
-TLV `LEN` 为 1 字节,表示单项 `VALUE` 字节数;整段 CodeInfo 总长度仍由 CODEINFO 描述符 `TLV_LEN16` 决定。内存入口 TLV 的地址宽度和区域由 `TYPE` 决定,名称字段由 `name_len8` 声明动态字节长度,单项 TLV 总长仍不能超过 255 字节。
+TLV `LEN` 为 1 字节,表示单项 `VALUE` 字节数;整段 CodeInfo 总长度仍由 CODEINFO 描述符 `TLV_LEN16` 决定。内存入口 TLV 的地址宽度和区域由 `TYPE` 决定,名称字段由 `name_len` 声明,名称本身为 UTF-8 或 ASCII 字节,不再固定 32 字节。
 
 同步到参数页后的规则:
 
@@ -219,7 +219,7 @@ CodeInfo 卡片始终显示在通讯页存储访问协议卡片内。板卡信
 
 ## 8. Bootloader 协议
 
-Bootloader 协议用于固件升级,独立于标准 Modbus 和存储访问。帧头固定为:
+Bootloader 协议用于固件升级。升级流程启动时先发送存储访问特殊指令复位帧 `0x41 + CRC`,随后在 500ms 握手窗口内每 50ms 发送一次 Bootloader 握手帧。Bootloader 升级帧头固定为:
 
 ```text
 46 54 PAYLOAD... CRC_H CRC_L
@@ -234,11 +234,13 @@ CRC 使用 `CRC16-CCITT-FALSE`,高字节在前。
 | 握手 | `39 42 4C` | 15 | 读取 Bootloader 版本和芯片 ID |
 | 解锁 | `08 4E 00` | 8 | 进入可编程状态 |
 | 编程 | `44 ADDR_L ADDR_H DATA(128B)` | 8 | 写入 128 字节程序块 |
-| 全 Flash 校验 | `19 43 43` | 9 | 读取 Flash 校验值 |
+| 全 Flash 校验 | `19 43 43` | 9 | 读取 Flash 校验值,返回数据部分低字节在前 |
 | 页擦除开关 | `08 50 45/44` | 8 | 开启或关闭页擦除 |
 | 退出 | `08 42 42` | 8 | 退出 Bootloader |
 
-ACK 为 `0x06`,NAK 为 `0x15`。固件文件加载、芯片型号识别、升级地址和 Flash 容量由 `features/bootloader/` 处理。
+ACK 为 `0x06`,NAK 为 `0x15`。固件文件加载、芯片型号识别、升级地址、Flash 容量、升级前复位和握手轮询由 `features/bootloader/` 处理。
+
+升级时保留固件最后一个不可擦写扇区:16K 固件从 `0x3F80` 开始不写入,32K 固件从 `0x7F00` 开始不写入。固件文件仍按完整 16K/32K 文件校验大小,编程循环只覆盖可擦写地址范围。
 
 ## 9. 维护约束