1. 项目概述与整体逆向思路手头有一台老设备通讯接口被厂家藏得严严实实数据手册上只写了私有协议不对外公开。设备里用的是光耦隔离的串口板子上预留了测试点固件也没加密但就是想搞清楚上位机和它之间到底在聊什么。这时候就需要一套不依赖任何文档、纯粹从硬件层面把通信协议猜出来的方法——这就是黑盒协议逆向。这个项目标题里其实藏着三个递进的层次先从物理层把信号摸清楚然后用示波器盲读参数接着处理光耦反相这个坑最后用单片机插桩的方式把数据流完整抓下来分析。整套流程走下来不需要逻辑分析仪不需要昂贵的协议分析仪一块常用的开发板加一台普通示波器就能干完。适合手里有未知设备需要对接、做产线测试工装、或者单纯想搞懂某个私有协议的朋友参考。先说结论黑盒逆向通信协议最忌讳一上来就接单片机乱抓数据。物理层不对后面全是白干。串口的电平标准、空闲电平、波特率、极性这四件事任何一项搞错抓回来的都是乱码。我见过太多人卡在为什么我收到的全是 0xFF或者为什么全是 0x00这种问题上排查到最后发现是光耦反相没处理。整套逆向流程可以拆成四大步物理层探查用示波器观察信号长什么样确定电平标准、空闲状态、信号极性。参数盲读从波形上估算波特率、帧格式确认起始位和停止位。信号调理处理光耦反相、电平转换等问题让信号恢复到单片机可以直接接收的状态。插桩采集与协议解析用单片机捕获完整的数据帧再通过统计分析把帧结构、命令字、校验算法还原出来。别小看这个流程每一步都有对应的坑。尤其是光耦反相项目名字里单独把它拎出来就是因为这个坑特别隐蔽而且一旦踩进去波形看起来一切正常数据却全是反的排查起来非常容易绕远路。1.1 为什么选择纯黑盒方案有人会问既然设备固件没加密为什么不直接读固件逆向代码答案是能读但不划算。很多设备用的是 OTP 单片机或者加密芯片固件根本读不出来就算读出来了汇编代码里翻协议解析逻辑花费的时间远比直接从物理层抓信号多得多。黑盒方案的优势在于不依赖设备内部实现只要信号在线上传输就能抓到。而且对于后期要做协议仿真、设备替身这类工作来说从物理层开始理解协议反而比看代码更接近本质。1.2 这套方案的适用范围与局限必须说清楚黑盒逆向不是万能的。它适合单点到单点的异步串行通信UART、RS232、RS485 等也就是一条发送线、一条接收线就能搞定的场景。如果遇到差分信号或者高速并行总线示波器带宽不够的话这套方案就不太行了。另外如果协议里带了动态加密或者每次通信都会更换密钥黑盒分析能还原出帧格式但解不出密文内容。不过在实际工业设备里绝大多数私有串口协议都是明文或者简单校验黑盒方案能覆盖掉 80% 以上的场景。2. 物理层信号探查与波形初读2.1 示波器接线与探头选择拿到设备之后第一步是找到通信接口的物理位置。常见的位置有接线端子排、DB9 串口座、板载测试点。优先找测试点因为很多设备在设计时会预留串口的 TXD 和 RXD 测试点方便产线调试。接线的时候注意几点探头地线夹必须可靠接地最好夹在设备的 GND 测试点上不要夹在机壳上机壳可能和电路板不共地。如果测量的是 RS485 差分信号用两个探头分别接 A 线和 B 线用通道相减的方式看差分波形。探头衰减档位先放在 10x防止信号幅值超出示波器量程。等看清幅值之后再调整到合适档位。我第一次测的时候犯过一个低级错误探头档位在 1x结果信号幅度直接超出屏幕范围还以为是设备输出异常。测之前先确认探头衰减倍率和示波器通道设置一致这个基本操作最容易出问题。2.2 判断电平标准与空闲状态示波器接上之后先什么都不做观察通信线在空闲状态的电平。UART 协议的空闲电平是逻辑高这个知识点在整个逆向过程中反复用到。区分电平标准主要看幅值电平标准逻辑高电平逻辑低电平典型应用场景TTL3.3V/5V0V单片机直接通信RS232-3V ~ -15V3V ~ 15V电脑串口、工控设备RS485A-B 差分正电压A-B 差分负电压工业现场总线如果是 RS232 电平示波器上会看到空闲状态在 -6V 到 -12V 之间发送数据时信号会跳到正电压。这时候如果直接接单片机的 RX 引脚轻则收不到数据重则烧毁引脚。必须用 MAX3232 之类的芯片做电平转换。如果是 TTL 电平直接看空闲电压是 3.3V 还是 5V这个决定了后面单片机插桩时需不需要做电平匹配。2.3 从波形上盲读波特率确定电平标准之后让设备持续发送数据示波器上就能看到连续的波形。测量最短脉冲宽度是估算波特率的关键操作。UART 协议每一位的时长是固定的一个位的时间等于波特率的倒数。正常发送数据时波形上最短的脉冲就是一位的时间。用示波器的光标功能测量最短低电平脉冲起始位之后的第一位。举个例子实测最短脉冲宽度 104 微秒那么波特率就是 1/0.000104 ≈ 9615接近标准的 9600bps。如果测出来是 8.68 微秒那波特率就是 115200。这里有个技巧不要只测一个脉冲多测几个不同的脉冲宽度。因为在随机数据里可能连续几位都是相同电平这时候测到的脉冲宽度会翻倍甚至更多。取所有脉冲宽度的最大公约数才是真正的单一位时间。比如测到三个脉冲宽度104微秒、208微秒、312微秒最大公约数是 104单一位时间就是 104 微秒波特率 9600。2.4 帧格式初判起始位、数据位、停止位波特率确认之后把示波器时基调整到合适范围完整抓取一帧数据。UART 帧结构是固定的空闲状态是高电平起始位是一个位时间的低电平接着是 8 个或者 5/6/7 个数据位低位在前结束是停止位一个位时间的高电平从波形上最直接的分辨方法起始位是所有帧里第一个拉低的信号而且之前必须有一个高电平的空闲期。如果两个帧之间间隔很短起始位的下降沿会非常明显。数据位的个数可以从停止位的位置反推。测出起始位下降沿到停止位上升沿之间的总时长减去起始位和停止位各一位的时间剩下的就是数据位时间。除以单位时间得到的就是数据位个数。绝大多数设备是 8 个数据位、1 个停止位、无校验但总有不按常理出牌的所以这一步不能省。3. 光耦反相的识别与信号调理3.1 光耦隔离电路为什么会导致反相光耦隔离是工业设备里非常常见的隔离方案作用是把设备内部电路和外部通信线路完全电气隔离防止干扰和地环路。原理不复杂输入侧是一个发光二极管输出侧是光敏三极管。输入侧通电发光输出侧就导通。问题就出在这个导通上。以最常见的集电极开路输出电路来说输入信号 TXD ── 发光二极管阳极 发光二极管阴极 ── GND输入侧 输出侧 VCC ── 上拉电阻 ── 输出引脚 RXD | 光敏三极管集电极 光敏三极管发射极 ── GND输出侧空闲状态下TXD 是高电平发光二极管不导通光敏三极管截止输出引脚被上拉电阻拉到高电平。发送数据时TXD 拉低发光二极管发光光敏三极管导通输出引脚被拉到低电平。这样看好像逻辑是对的输入高输出高输入低输出低。但注意有的光耦电路设计是低电平点亮发光二极管也就是把发光二极管接在 VCC 和信号之间信号拉低时发光。这种电路下输入高时输出低输入低时输出高逻辑就完全反过来了。还有一种情况是光耦输出侧接了反向电路比如三极管放大级也会把信号再反一次。所以拿到波形之后第一件事是确认空闲电平和预期是否一致。3.2 从波形上识别信号已反相接上示波器观察波形如果发现以下特征基本可以断定信号被反相了空闲状态是低电平而 UART 协议空闲应该是高电平数据波形看起来上下颠倒高电平脉冲变成了低电平脉冲用示波器自带的串口解码功能解析数据全是 0xFF 或者 0x00 这种规律性的错误值反相后的波形很容易骗人因为看起来还是有数据在传输只是每一位的逻辑值都反了。如果直接用单片机 UART 外设接收会收到一堆错误数据而且错误形式非常规律。3.3 解决光耦反相的三种方案处理反相信号根据实际场景有三条路可以走方案一硬件上加反相器。这是最直接也最可靠的方法。如果信号是从光耦输出端引出在进入单片机之前加一个三极管反相电路或者用逻辑门芯片比如 74HC04反相一次把信号恢复成正常极性。优点是硬件处理完后面所有软件都不用特殊照顾。缺点是需要动电路改板子或者飞线。方案二利用 UART 外设的奇偶校验特性。这个方法比较取巧适用于 MCU 刚好支持硬件奇偶校验的场合。思路是把反相信号直接接到单片机 RX 引脚配置 UART 时把空闲电平为高理解成空闲电平为低并且把数据的逻辑进行反转。最简单的方式是用带极性配置的 USART比如 STM32 的部分型号没有这个功能但有些国产 MCU 支持 RXINV 这类位。方案三外部中断 定时器软解码。如果单片机 UART 外设不够用或者不想依赖硬件特性可以用一个外部中断引脚接反相信号配置成下降沿触发。反相信号里原来的上升沿变成了下降沿原来的下降沿变成了上升沿。通过记录相邻两次中断的时间间隔推算出每一位的电平再自己拼装字节。这个方法最麻烦但兼容性最好。我实际项目里最常用的是方案三的变体用一个通用定时器的输入捕获功能代替外部中断精度更高不容易漏中断。后续章节会详细展开。3.4 实测案例一个被反相坑了两天的项目说个真实经历。之前帮客户分析一台老式注塑机的通信协议示波器探上去波形稳定、幅值正常、波特率能对上但接上单片机收到的数据永远是 0xAA 和 0x55 交替出现。当时我一度以为协议就是这么设计的直到用示波器单帧完整抓下来手动按位翻译了一遍才发现每一位的数据都和实际控制指令对不上。后来翻了设备的主控板发现光耦那里用的是低电平点亮发光二极管的电路。这就是项目标题里光耦反相四个字的由来——它不是一个理论问题而是实际逆向过程中最容易踩的隐蔽陷阱。从那之后我的固定动作是先量空闲电平空闲不对直接判死刑不用往后看数据。4. 单片机插桩采集与数据捕获4.1 为什么选择单片机插桩而不是逻辑分析仪正规的协议逆向工具是逻辑分析仪采样率高、通道多、软件解码自动完成。但实际出差去现场很难随身带一套昂贵的分析设备而且很多测试环境根本不允许接太多设备。用单片机插桩采集成本低、便携、接线简单而且可以直接进代码里做后续的协议分析。挑单片机的时候只需要满足三个条件有硬件 UART 或者具备输入捕获功能的定时器有足够的内存做数据缓冲区能方便地把数据导出来串口、USB、SD 卡都行STM32F103 是我常用的选择主频 72MHz定时器带输入捕获内存 20KB抓串口数据绰绰有余。4.2 输入捕获方式的硬件连接先看硬件接线。目标是捕捉经过调理后的信号也就是已经处理好光耦反相、电平转换的信号。把这个信号同时接到两个地方单片机 UART 的 RX 引脚一个定时器的输入捕获通道这样做的目的是双保险UART 外设负责高速接收正常数据输入捕获负责精确记录每个边沿的时间戳。在协议分析阶段时间戳比原始数据更有价值因为通过时间戳能反推每一位的宽度、帧间间隔、以及是否存在异常毛刺。接线注意点信号线尽量短避免引入干扰如果信号来自外部设备建议先经过一个 100 欧姆的串联电阻再进单片机引脚起到限流保护作用单片机 GND 必须和设备 GND 可靠共地否则什么都白搭4.3 定时器输入捕获的初始化代码以 STM32F103 的 TIM2 为例配置 CH1 作为输入捕获工作在上升沿和下降沿都捕获的模式void TIM2_InputCapture_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_ICInitTypeDef TIM_ICInitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; // 开启时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // PA0 复用为 TIM2_CH1 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // 定时器时基72MHz / 36 2MHz即每个计数单位 0.5us TIM_TimeBaseStructure.TIM_Period 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler 36 - 1; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); // 输入捕获配置直接捕获上升沿和下降沿都捕获 TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_BothEdge; // 双沿捕获 TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x0F; // 滤波防止毛刺误触发 TIM_ICInit(TIM2, TIM_ICInitStructure); // 使能捕获中断 TIM_ITConfig(TIM2, TIM_IT_CC1, ENABLE); // 启动定时器 TIM_Cmd(TIM2, ENABLE); // 使能 NVIC 中断 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }解释几个关键参数预分频 3672MHz/362MHz计数器每 0.5us 加一。选这个精度是为了后续能精确测量 9600 波特率单 bit 104us和 115200 波特率单 bit 8.68us的脉冲宽度。双沿捕获一次捕获记录上升沿下一次记录下降沿交替进行。这样每两个连续时间戳之间的差值就是一个脉冲的宽度。滤波 0x0F开启输入滤波避免信号线上的毛刺触发中断。在工业现场这个设置很有必要实测能滤掉大部分窄脉冲干扰。4.4 捕获中断的数据存储策略捕获中断里最怕的是处理时间太长错过下一次边沿。所以我采用环形缓冲区 标志位的经典结构中断只做一件事把定时器的计数值存到数组里然后立即退出。#define EDGE_BUF_SIZE 4096 volatile uint16_t edge_buf[EDGE_BUF_SIZE]; volatile uint16_t edge_idx 0; volatile uint16_t edge_count 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_CC1) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); // 读取捕获值存入环形缓冲区 uint16_t capture TIM_GetCapture1(TIM2); edge_buf[edge_idx] capture; edge_idx (edge_idx 1) (EDGE_BUF_SIZE - 1); edge_count; } }缓冲区大小 4096 个 16 位数总共 8KB。对于 9600 波特率、每帧十几个字节的数据量来说足够存下上百帧的边沿信息。缓冲区用的环形结构配合按位与操作取模效率很高。主程序里通过检查edge_count是否在增长来判断是否有新数据。抓完一段数据后把边沿时间戳数组通过串口发送到 PC 端在 PC 上做离线分析。4.5 PC 端时间戳解析辅助代码拿到边沿时间戳之后第一个任务是恢复出位序列。算法思路是从第一个边沿开始记录相邻边沿的时间差根据时间差判断是高电平还是低电平。以 9600 波特率为例单 bit 时间约 104us。由于采样精度是 0.5us所以时间差以 0.5us 为单位存储实际判断时把时间差换算回微秒def parse_edges(edges_us, bit_time_us): edges_us: 相邻边沿间隔微秒列表 bit_time_us: 单 bit 时长微秒 bits [] for duration in edges_us: # 四舍五入到最近的 bit 整数倍 num_bits round(duration / bit_time_us) if num_bits 1: continue # 根据当前电平状态填充 bit # 具体填充逻辑需要知道起始电平UART 空闲是高电平 # 起始位拉低后第一个边沿是下降沿之后交替 bits.extend([None] * num_bits) return bits实际的位序列还原还需要知道每个边沿是上升还是下降这可以从示波器上观察也可以从捕获的时间戳和起始电平推断。这里不把完整代码贴出来了关键思路是先按时间差把每个位段切出来再根据高低电平交替的规律填充每一位的数据。5. 协议解析方法与帧结构还原5.1 从原始位流到字节流边沿时间戳还原成位序列之后接下来要解决字节对齐的问题。UART 帧结构是固定的起始位 1 位数据位 8 位停止位 1 位。所以从起始位下降沿开始数 1 位起始位然后连续读 8 位作为数据再读 1 位停止位确认是高电平正常停止位必须是高电平一帧数据就还原出来了。关键步骤找到下降沿作为起始位开始跳过起始位1 bit连续读 8 个 bit低位在前拼接成字节读停止位如果停止位不是高电平说明前面对齐可能错了或者有奇偶校验位有一个很容易忽略的问题有的设备会自己加校验位。如果停止位之前总有一个多余的位而且这个位的值随着前面数据变化八成是校验位。判别方法是统计大量帧数据看这个位的值是否满足偶校验或奇校验规则。5.2 帧结构分析与字段猜测字节流拿到手后下一步是分析帧结构。我一般会把数据导到一个表格里按行排列每帧数据然后逐列观察。典型的做法是固定字节识别所有帧的第一个字节和第二个字节如果都相同大概率是帧头或者地址字段。长度字段识别找到某个字节的值和整帧长度有固定数学关系通常是差值或者位移。命令字识别改变设备的工作状态观察哪一列字节跟着变那个位置大概率是命令或者寄存器地址。数据字段识别连续变化的字节可能是传感器数据或者状态码。校验字段识别帧尾附近总有一个字节和前面所有字节存在某种函数关系。最经典的验证方式是改一个操作看哪列变了。比如手动按一下设备上的按钮对比前后两帧数据变化的字节就是命令字段比如给设备输入一个已知数值的模拟量看哪几个字节和这个数值相关那就是数据字段。5.3 校验算法的破解思路校验字段是协议逆向里最需要耐心的一环。常见的有和校验、异或校验、CRC8、CRC16。第一步是确认校验参与的范围。一般是从帧头之后开始算到校验字段之前。拿几组帧数据做试探把所有参与字节求和和校验字段比较把所有参与字节异或和校验字段比较尝试 CRC8 常用的几个多项式比如 0x07、0x31、0x8C如果设备是自己设计的用和校验的概率最大因为实现简单。如果是工业仪表CRC16 概率大。分享一个经验如果校验值和数据本身呈线性关系优先考虑 CRC 变体。CRC 本质上是多项式除法余数和数据的位运算强相关。把多组数据代入在线 CRC 计算工具验证多项式一般半小时内能解出来。6. 常见问题与排查技巧实录6.1 波形异常排查速查表现象可能原因排查方向波形时有时无接触不良、地线未接好重新夹地线换探头空闲电平不对光耦反相、电平标准判断错确认空闲电平检查电路数据全是 0xFF信号反相未处理加反相器或用软解码反转数据全是 0x00总线被拉死、对地短路查 PCB 走线、断开后级恢复波特率对不上测到的是多倍脉宽取最大公约数重新算帧结构解析乱数据位个数判断错数停止位位置确认是否有校验位6.2 光耦反相和软件反转大法如果现场条件不允许改硬件又必须用 UART 外设接收反相信号有一个取巧的办法强制配置 UART 的极性和波特率利用停止位造成的数据错位来做软件反转。这个方法不推荐新手上来就用但对某些特定场景很好使。大概思路是不依赖 UART 硬件自动解析帧格式而是用定时器中断对 RX 引脚进行高频采样自己判断起始位和每一位电平直接把反转的逻辑写在采样函数里。缺点是占用 CPU 资源多但波特率不高9600的场景下完全可行。6.3 插桩采集时的常见错误缓冲区溢出是最常见的问题。采集时间一长边沿缓冲区写满新数据覆盖旧数据导致解析出来的时间戳序列错乱。解决办法有两个一个是加大缓冲区另一个是设计触发条件只在检测到起始位低电平之后才开始记录。还有一个容易犯的错是定时器溢出没处理。16 位定时器计数范围 0~65535在 2MHz 计数频率下最多计 32767us也就是 32.7ms。如果两个边沿之间间隔超过这个时间比如设备一帧数据发送完后停顿了 200ms计数会回绕记录的时间戳就不连续了。处理方案是在中断里检测计数器溢出标志额外记录溢出次数计算时间差时把溢出次数也换算进去。6.4 现场实战时的工具配置清单最后整理一份现场工具箱都是我实际用下来觉得最顺手的配置示波器带宽 100MHz带串口解码功能没有也能干探头标配 10x 无源探头两支电平转换模块TTL 转 RS232、TTL 转 RS485 各一块反相器模块74HC04 逻辑门小板用来处理光耦反相单片机开发板STM32F103 最小系统板引出 PA0 和 UART1杜邦线若干最好不同颜色区分信号线、地线、电源线这套配置我放在包里随时待命基本能应对 90% 的协议逆向需求。6.5 协议逆向的终极心得做到这一步协议大概已经用了两三天时间。贪快是大忌有耐心慢慢观察、慢慢猜反而更快。每确认一个字段的含义就在笔记本上画一个示意图。等所有字段都标注清楚这个黑盒设备在你的视角里就等同于变成一个透明盒了。最后说个小技巧把整帧数据按十六进制格式打印出来经常盯着看很多规律会自己浮现出来。人的眼睛很擅长找模式这是任何自动分析工具都比不上的。