Python实现YModem协议串口通信:原理与pyserial代码示例

橘子海
橘子海
发布时间:2026-11-09| 6人浏览过
原创

深入解析YModem协议核心原理、帧格式定义与握手状态机,并基于Python与pyserial库提供完整的YModem发送端代码示例,适用于MCU固件升级与嵌入式串口文件传输。

应用方向:嵌入式固件传输 / MCU IAP 在线升级 通信协议 Python 3 pyserial
Python实现YModem协议串口通信:原理与pyserial代码示例
图 0:基于 Python 上位机与单片机 Bootloader 的 YModem 串口通信传输全景
YModem 是一种基于串口的可靠二进制文件传输协议,广泛应用于单片机固件 IAP(在应用编程)升级与嵌入式系统诊断。本文直接解析 YModem 协议的帧结构、握手与校验机制,并基于 pyserial 库实现一套具备超时重传与完整异常处理的 Python 发送端代码,帮助读者在上位机与目标嵌入式硬件之间建立稳定、规范的文件通信信道。

一、YModem 协议核心原理与帧格式定义

在嵌入式开发中,若直接通过原始串口字节流发送固件,极易因偶发噪波、波特率抖动或接收端 Flash 写入延时出现丢字节或乱序,且接收端无法感知传输是否完整。YModem 在经典 XModem 协议基础上演进而来,引入了文件名与大小元数据传递、1024 字节大包拓展以及多重校验机制,成为各类 MCU Bootloader(如 STM32 官方 IAP 示例)的标准升级协议。

1. 核心控制字符

YModem 协议在通信链路中通过单字节控制字符完成会话发起、确认、重发及中断协调。下表列出通信两端必须识别的标准控制字符定义:

控制字符 十六进制 十进制 角色与功能定义
SOH (Start of Header) 0x01 1 指示后续数据载荷为标准 128 字节长度的数据包
STX (Start of Text) 0x02 2 指示后续数据载荷为 1024 字节长度的高吞吐率数据包(YModem-1K)
EOT (End of Transmission) 0x04 4 发送端宣告文件数据发送完毕,协议中要求双重交互确认
ACK (Acknowledge) 0x06 6 接收端确认单包数据正确接收(序号与 CRC 校验均合法)
NAK (Negative Acknowledge) 0x15 21 接收端请求重传上一包数据(CRC 不匹配或序号跳变)
CAN (Cancel) 0x18 24 紧急中止,通信双方任意一方连续发送两个 CAN 字符即可强行终结会话
'C' (ASCII Capital C) 0x43 67 接收端发起轮询,通知发送端使用 CRC-16 校验模式建立握手

2. 数据帧通用组成结构

无论是起始帧还是后续数据帧,YModem 的物理帧结构均由五个固定部分拼接而成:

  • 帧起始标识(1 字节):取值为 SOH (0x01) 或 STX (0x02),同时决定了该帧的数据净载荷长度是 128 字节还是 1024 字节。
  • 包序号(1 字节):取值范围为 0x00 ~ 0xFF。起始帧固定为 0x00,真实数据块从 0x01 递增,超过 255 后自动回绕到 0 循环计数。
  • 包序号反码(1 字节):取值为 255 - 包序号。用于接收端校验序号本身的电气完整性。接收端计算 (seq + ~seq) & 0xFF == 0xFF,若不相等则直接判定帧头错位,丢弃并回复 NAK。
  • 数据有效载荷(128 或 1024 字节):固定长度。若实际数据不足包长,末尾使用 0x00(或 0x1A CPMEOF)补齐。
  • CRC-16 校验码(2 字节):采用大端模式(Big-Endian),先传高 8 位,后传低 8 位。校验计算范围仅包含数据载荷部分,不计算前置的 3 字节帧头。
Python实现YModem协议串口通信:原理与pyserial代码示例
图 1:YModem 数据包整体结构与起始包 Block 0 载荷组织规范

3. 起始帧(Block 0)的特殊设计

与普通数据包不同,包序号为 0x00 的数据包被称为 Block 0。它不承载固件主体,而是承载传输任务的元数据:

  • 文件名字段:以 ASCII 编码存储文件名(例如 app_v1.0.bin),后紧跟一个 0x00 作为结束分隔符。
  • 文件大小字段:紧随其后,以十进制 ASCII 字符串记录文件的总字节数(例如 "32768"),同样以 0x00 分隔。接收端根据此大小即可在写入 Flash 时准确截断末尾填充字节,避免误将补齐填充写入镜像。
  • 剩余空间填充:将该数据块的剩余部分全部填充为 0x00,直至凑满 128 字节(或 1024 字节)。
  • 任务终结标识:当整个传输任务结束时,发送端会再次发送一个包序号为 0、载荷全为 0x00 的空 Block 0,明确通知接收端不会再有新文件进入批处理。

4. 关键差异对比:128 字节 SOH 帧与 1024 字节 STX 帧

在实际工程实现中,YModem 允许选用 128 字节(SOH)或 1024 字节(STX)作为数据切片单位。理解这两者的关键差异对于保证固件升级的效率与稳定性至关重要:

对比维度 128 字节帧 (SOH) 1024 字节帧 (STX) 工程影响与机理解释
协议固定开销比 约 3.76% (5B 开销 / 133B 总长) 约 0.49% (5B 开销 / 1029B 总长) STX 模式下的线路有效数据占比大幅提高,协议头浪费显著减少。
往返确认时延累积 包数量为 STX 的 8 倍,往返等待极大 包数量仅为 1/8,往返通信开销低 串口每次发包后必须停止发送并等待接收端回复 ACK。每包往返时延(RTT)通常为 5~20ms,100KB 文件使用 SOH 需等待 800 次往返,而 STX 仅需 100 次,实际总耗时差距可达 2~3 倍。
误码重传成本 误码惩罚轻微(仅重发 133 字节) 误码惩罚严重(需重传 1029 字节) 在电磁干扰强、波特率极高(如 921600bps)或长距离 RS485 总线上,单个误码比特将导致整个 1KB 大包重传,重试频发反而会导致实际吞吐率断崖式下滑。
接收端 RAM 硬件门槛 极低(仅需约 160 字节接收缓冲) 较高(单包缓冲至少需要 1.2KB RAM) 低端 Cortex-M0/8 位 MCU 的 SRAM 往往仅有 1KB~2KB,根本无法开辟 1024 字节的接收缓冲区,强行使用 STX 会造成栈溢出或内存崩溃。
选型结论:对于片内 SRAM 充裕(大于 8KB)且串口质量良好的常规单片机系统(如 STM32F1/F4/H7 等),应优先采用 1024 字节 STX 帧以获得最高传输效率;而对于低端微控制器、长距离干扰总线或仅需传输几百字节配置文件的场景,应强制退化为 128 字节 SOH 帧。

二、完整交互流程与状态机模型

YModem 是一种严格的“发一包、等一包、收一包确认”的停等式(Stop-and-Wait)协议。一个标准的文件会话严格划分为协商握手、数据流循环与终止确认三个阶段。

Python实现YModem协议串口通信:原理与pyserial代码示例
图 2:YModem 协议端到端完整传输时序与控制字符交互流程

1. 三阶段时序拆解

  1. 第一阶段:协商与元数据传输
    接收端启动后进入 Bootloader 监听状态,每隔 1~2 秒主动向串口发送字符 'C' (0x43)。上位机捕捉到 'C' 后,立即组装包序号为 0x00 的 Block 0(包含待发固件的文件名和大小),以 SOH 或 STX 发出。接收端收到后进行校验,若正确则回复 ACK (0x06),紧接着主动发送第二个 'C',指示发送端可以启动主体数据传输。
  2. 第二阶段:数据流分包发送与逐包确认
    发送端从偏移量 0 开始读取文件二进制数据,每次截取 1024 字节(不足补齐),包序号从 0x01 递增,发送 [STX | seq | ~seq | data | CRC]。发送端阻塞等待接收端返回的响应字符: 若为 ACK,则推进包序号并截取下一块数据;若为 NAK 或超时,则保持包序号不变重新发包;若为 CAN 则立即熔断。
  3. 第三阶段:文件结束与双重握手终止
    文件全部数据发完并得到最后一个 ACK 后,发送端向接收端发送首个 EOT (0x04)。 注意协议细节:接收端为了防止偶发杂波误触 EOT,按 YModem 规范首次会回复 NAK (0x15)。发送端收到该 NAK 后,必须发送第二个 EOT;接收端此时确认真为文件尾,回复 ACK 并紧接着发送一个 'C'。发送端最后发送一个全 0x00 的空 Block 0,接收端再次回复 ACK,整个串口会话正式彻底终结。
Python实现YModem协议串口通信:原理与pyserial代码示例
图 3:传输异常拦截、重传机制与容错状态迁移图

三、Python 与 pyserial 完整代码实现

编写可靠的 YModem 传输工具,关键在于严谨的 CRC-16 纯纯度计算、精准的超时重发控制以及完备的状态机收尾处理。以下基于标准库和 pyserial 进行完整实现。

1. 环境准备与依赖

# 安装 pyserial 串口通信核心库
pip install pyserial

2. 完整发送端类实现(可直接运行)

# -*- coding: utf-8 -*-
"""
Python YModem 发送端实现示例
基于 pyserial,支持 128B (SOH) 与 1024B (STX) 模式、完整握手、超时重传与中断响应
"""

import os
import sys
import time
import struct
import serial

class YModemError(Exception):
    """YModem 协议基础异常"""
    pass

class YModemTimeoutError(YModemError):
    """超时未收到应答异常"""
    pass

class YModemAbortedError(YModemError):
    """接收端或发送端强制中止异常"""
    pass


def calc_crc16(data: bytes) -> int:
    """
    计算 CRC-16-CCITT 校验码 (多项式 0x1021, 初始值 0x0000, 高位在先)
    :param data: 待计算的二进制字节串
    :return: 16位无符号整数校验和
    """
    crc = 0x0000
    for byte in data:
        crc ^= (byte << 8)
        for _ in range(8):
            if crc & 0x8000:
                crc = ((crc << 1) ^ 0x1021) & 0xFFFF
            else:
                crc = (crc << 1) & 0xFFFF
    return crc


class YModemSender:
    # 常用协议控制字符定义
    SOH = b'\x01'  # 128 字节帧头
    STX = b'\x02'  # 1024 字节帧头
    EOT = b'\x04'  # 结束传输
    ACK = b'\x06'  # 确认应答
    NAK = b'\x15'  # 否认重发
    CAN = b'\x18'  # 取消传输
    CRC_C = b'\x43'  # 字符 'C'

    def __init__(self, ser: serial.Serial, max_retries: int = 10, packet_timeout: float = 3.0):
        """
        初始化 YModem 发送引擎
        :param ser: 已成功初始化的 serial.Serial 实例
        :param max_retries: 单包最大允许重试次数
        :param packet_timeout: 单字符/应答等待超时时间(秒)
        """
        self.ser = ser
        self.max_retries = max_retries
        self.packet_timeout = packet_timeout

    def _read_byte(self, timeout: float = None) -> bytes:
        """从串口读取单个字节,带超时检查"""
        prev_timeout = self.ser.timeout
        if timeout is not None:
            self.ser.timeout = timeout
        try:
            char = self.ser.read(1)
            return char
        finally:
            self.ser.timeout = prev_timeout

    def _build_packet(self, header: bytes, seq: int, data: bytes) -> bytes:
        """
        组装符合规范的单个 YModem 帧
        格式: [Header(1B)] [Seq(1B)] [~Seq(1B)] [Data(NB)] [CRC_H(1B)] [CRC_L(1B)]
        """
        seq_byte = seq & 0xFF
        seq_inv_byte = (~seq) & 0xFF
        crc_val = calc_crc16(data)
        crc_bytes = struct.pack(">H", crc_val)
        return header + bytes([seq_byte, seq_inv_byte]) + data + crc_bytes

    def _send_and_wait_ack(self, packet: bytes) -> bool:
        """发送单包数据并等待 ACK,遇 NAK 或超时自动重传"""
        retries = 0
        while retries < self.max_retries:
            self.ser.write(packet)
            self.ser.flush()

            # 等待接收端响应字符
            resp = self._read_byte(self.packet_timeout)

            if resp == self.ACK:
                return True
            elif resp == self.NAK:
                # 接收端指示校验失败,重发当前包
                retries += 1
                continue
            elif resp == self.CAN:
                # 检查是否为双连续 CAN 中止
                next_char = self._read_byte(1.0)
                if next_char == self.CAN:
                    raise YModemAbortedError("接收端发送连续 CAN,传输已强制终止")
                retries += 1
            elif not resp:
                # 响应超时,清空输入缓冲重试
                self.ser.reset_input_buffer()
                retries += 1
            else:
                # 收到其他非预期字符(如残留杂波),重试
                retries += 1

        raise YModemTimeoutError(f"包重发超过最大上限 {self.max_retries} 次,通信中断")

    def send_file(self, file_path: str, use_1k: bool = True, progress_callback=None):
        """
        执行完整 YModem 协议发送流程
        :param file_path: 本地待发送固件文件绝对或相对路径
        :param use_1k: True 使用 1024 字节 STX 包,False 使用 128 字节 SOH 包
        :param progress_callback: 进度回调函数 callback(sent_bytes, total_bytes)
        """
        if not os.path.exists(file_path):
            raise FileNotFoundError(f"目标文件不存在: {file_path}")

        file_size = os.path.getsize(file_path)
        file_name = os.path.basename(file_path)

        # 阶段一:等待接收端发送初始 'C' 字符发起握手
        print("[-] 等待接收端就绪信号 ('C')...")
        waiting_c_retries = 0
        while waiting_c_retries < 30:
            char = self._read_byte(timeout=2.0)
            if char == self.CRC_C:
                print("[+] 捕获 'C' 握手信号,准备发送 Block 0...")
                break
            elif char == self.CAN:
                raise YModemAbortedError("接收端在握手阶段发送取消字符")
            waiting_c_retries += 1
        else:
            raise YModemTimeoutError("等待接收端启动握手超时(未收到 'C')")

        # 组装 Block 0(包含文件名与文件大小字符串)
        block0_payload = file_name.encode("utf-8") + b'\x00' + str(file_size).encode("ascii") + b'\x00'
        # 起始帧统一填充至 128 字节(部分单片机仅按 128B 解析 Block 0)
        block0_payload = block0_payload.ljust(128, b'\x00')
        block0_packet = self._build_packet(self.SOH, 0, block0_payload)

        # 发送 Block 0
        self._send_and_wait_ack(block0_packet)

        # 接收端确认 Block 0 后,必须再次发送 'C' 请求数据
        resp = self._read_byte(self.packet_timeout)
        if resp != self.CRC_C:
            raise YModemError(f"Block 0 确认后未收到数据引导字符 'C',收到: {resp}")

        # 阶段二:读取文件并分包发送
        data_block_size = 1024 if use_1k else 128
        header_byte = self.STX if use_1k else self.SOH

        seq = 1
        sent_bytes = 0

        with open(file_path, "rb") as f:
            while True:
                chunk = f.read(data_block_size)
                if not chunk:
                    break

                # 末包不足补齐 0x00
                if len(chunk) < data_block_size:
                    padded_chunk = chunk.ljust(data_block_size, b'\x00')
                else:
                    padded_chunk = chunk

                packet = self._build_packet(header_byte, seq, padded_chunk)
                self._send_and_wait_ack(packet)

                sent_bytes += len(chunk)
                if progress_callback:
                    progress_callback(min(sent_bytes, file_size), file_size)

                seq = (seq + 1) % 256

        # 阶段三:结束文件传输(双 EOT 机制)
        print("\n[-] 数据发送完成,开始结束握手...")
        # 发送第 1 个 EOT
        self.ser.write(self.EOT)
        self.ser.flush()

        resp = self._read_byte(self.packet_timeout)
        if resp == self.NAK:
            # 协议要求:收到 NAK 后发第 2 个 EOT
            self.ser.write(self.EOT)
            self.ser.flush()
            resp = self._read_byte(self.packet_timeout)

        if resp != self.ACK:
            raise YModemError(f"EOT 确认失败,未收到 ACK,收到: {resp}")

        # 接收端回复 ACK 后会发送 'C',请求下一文件或终结
        resp = self._read_byte(self.packet_timeout)
        if resp == self.CRC_C:
            # 发送全 0 空 Block 0 终结整个批处理会话
            empty_block0 = self._build_packet(self.SOH, 0, b'\x00' * 128)
            self.ser.write(empty_block0)
            self.ser.flush()
            final_ack = self._read_byte(self.packet_timeout)
            if final_ack != self.ACK:
                print(f"[!] 警告: 终结帧未收到 ACK,收到: {final_ack}")

        print("[+] YModem 文件传输流程全部顺利完成!")


# 命令行测试驱动示例
if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Python YModem 固件串口发送工具")
    parser.add_argument("-p", "--port", required=True, help="串口号 (如 COM3 或 /dev/ttyUSB0)")
    parser.add_argument("-b", "--baud", type=int, default=115200, help="波特率 (默认 115200)")
    parser.add_argument("-f", "--file", required=True, help="待发送固件文件路径")
    parser.add_argument("--use-128b", action="store_true", help="强制使用 128 字节 SOH 帧(默认使用 1024 字节 STX)")

    args = parser.parse_args()

    # 打开串口连接
    try:
        serial_conn = serial.Serial(
            port=args.port,
            baudrate=args.baud,
            bytesize=serial.EIGHTBITS,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE,
            timeout=2.0
        )
    except serial.SerialException as err:
        print(f"[x] 打开串口失败: {err}")
        sys.exit(1)

    def print_progress(current, total):
        pct = (current / total) * 100
        bar_len = 30
        filled = int(bar_len * current // total)
        bar = "=" * filled + ">" + "-" * (bar_len - filled - 1) if filled < bar_len else "=" * bar_len
        sys.stdout.write(f"\r[传输进度] [{bar}] {pct:.1f}% ({current}/{total} Bytes)")
        sys.stdout.flush()

    try:
        sender = YModemSender(serial_conn)
        use_stx = not args.use_128b
        print(f"[+] 串口 {args.port} 已就绪,波特率: {args.baud},帧规格: {'1024B (STX)' if use_stx else '128B (SOH)'}")
        sender.send_file(args.file, use_1k=use_stx, progress_callback=print_progress)
    except Exception as e:
        print(f"\n[x] 传输异常中断: {e}")
    finally:
        serial_conn.close()
        print("[+] 串口资源已释放")

四、联调实战与嵌入式常见排查点

在实际连接 STM32 或其它 MCU 进行串口 IAP 联调时,常因物理线路或时序配合出现异常。下表总结了高频故障现象及精准定位方案:

Python实现YModem协议串口通信:原理与pyserial代码示例
图 4:YModem 协议发送端完整状态机转换与全生命周期总结
异常表现 底层根本原因 实战排查与消除策略
发送端提示“等待接收端启动握手超时(未收到 'C')” 1. 串口 TX/RX 硬件交叉未正确对调;
2. 目标板未复位或未进入 Bootloader 接收死循环;
3. 目标板波特率配置不一致。
1. 用逻辑分析仪或示波器检查串口引脚是否有周期性 0x43 脉冲;
2. 确认串口收发线是否接反(上位机 TX 接板端 RX);
3. 检查单片机时钟配置(HSE/HSI 漂移会导致实际波特率偏差超过 3% 从而解码失败)。
首包 Block 0 发出后立即收到 NAK 或 CAN 熔断 1. 文件名包含全路径符号(如 / 或 \);
2. 文件名字节超出接收端预分配静态数组;
3. 目标板在接收 Block 0 后需要执行全片 Flash 擦除,耗时过长导致超时。
1. 代码中务必使用 os.path.basename 仅提取纯文件名;
2. 很多 Bootloader 收到 Block 0 后会触发全 Flash 擦除(可能耗时 2~5 秒)。此时需将上位机首包等待超时时间调大至 5~10 秒,避免上位机误判超时。
数据传输在中间固定位置(如每 2KB/4KB)卡顿或校验错 单片机在每接收满一个 Flash 扇区(如 2KB 页)时会阻塞写入物理存储,写入期间硬件 UART 接收 FIFO 溢出导致丢失后续字节。 1. 在单片机端引入串口 DMA 双缓冲机制(Ping-Pong Buffer);
2. 若单片机端为中断阻塞接收,上位机端可在每发完一包并收到 ACK 后加入微量延时(如 time.sleep(0.005)),让出 MCU 写 Flash 的时间窗口。
进度条达到 100% 但目标板不跳转执行新固件 双 EOT 握手不完整,或最后的空 Block 0 终结包未被正确发送与接收。 很多 MCU Bootloader 将“全 0 空 Block 0 的 ACK 回复”作为校验通过并跳转执行用户应用程序(Jump to User App)的触发点。务必确保执行完双 EOT 后发送 128 字节全 0 帧并得到最终 ACK。
调试技巧:若调试过程中协议无法对齐,可在 _read_byte 和 write 方法前后增加十六进制 HexDump 打印(如 print(f"TX -> {packet[:8].hex()}... CRC: {packet[-2:].hex()}")),通过清晰的字节流对照图 2 的交互时序,即可在两分钟内锁定是哪一方违反了状态机。
相关专题:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。