news 2026/10/2 5:49:28

STM32曼彻斯特编码自同步通信:定时器DMA编解码与抗干扰

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32曼彻斯特编码自同步通信:定时器DMA编解码与抗干扰

前阵子帮一位做工业采集的朋友调板子,场景很朴素:一块 STM32 从机负责采 8 路模拟量,一根三芯线(电源、信号、地)拉到三米外的主机板。第一版直接用 UART,115200 波特率,台面上跑得好好的,装到机柜里就间歇性丢字节。用示波器一量,两板之间的地电位差随着大功率设备启停来回跳,接收端的判决门限跟着漂,再加上线材本身是个低通,方波的边沿被磨成了圆角,采样点正好落在最难受的位置。换过 RS485 收发器、加过隔离,成本上去了,但项目本身利润薄,不想再堆料。最后改了一个思路:既然收发两端都是自己写的固件,那就把时钟信息直接塞进波形里,用曼彻斯特编码做基带传输,接收端靠波形自身的跳变恢复节拍,不用再跟绝对电平和长期漂移较劲。改完之后连续跑了七十二小时,误码率是零。

这件事让我意识到,曼彻斯特编码这东西虽然课本上讲得很早,但真正到了要手写编解码、要抠定时器参数的时候,能落地的资料并不多。搜出来的内容要么停在"中间跳变表示时钟、跳变方向表示数据"这一句,要么直接给你一段 MATLAB 仿真,跟 STM32 的定时器和 DMA 完全对不上。所以这篇我就按自己实际写过的代码、算过的参数,把这条路完整走一遍:它到底解决什么问题、两种极性约定怎么区分、在 STM32 上用定时器和 DMA 怎么把波形生出来、接收端怎么从边沿里把比特抠出来、以及那些只有踩过才知道的坑。不管你是刚接触嵌入式的新手,还是做了几年想补一下通信基础的工程师,看完应该都能直接抄着改。

1. 为什么还要在 2025 年折腾曼彻斯特编码

1.1 一个典型取舍现场:两线制数据回传

先把这个场景说透,因为脱离了场景去谈编码方式,全是空话。上面说的那根三芯线,实际约束是这样的:线长三米到十米不等,现场有大功率变频器在附近工作,共模干扰明显;两端都是 STM32,一端是 F103,一端是 F407;数据量不大,每 10 毫秒上报一帧,一帧 16 字节左右;成本敏感,不想加隔离芯片和专用收发器。

这种条件下,能选的方案其实就那么几个。SPI 只能板内用,走不了线;I2C 抗干扰能力弱,线长了电容直接把上升沿吃掉;UART 是异步的,接收端靠自己的内部时钟去采样,一旦收发两端的时钟偏差累积到半个位周期,采样点就滑出去了,而且它同样怕共模干扰。CAN 需要收发器,成本上去了还得配对端电阻。以太网就更不用说了。

曼彻斯特编码在这类场景里的价值就出来了——它是自同步的。所谓自同步,就是接收端不需要事先知道发送端的时钟频率是多少,也不需要一根单独的时钟线,只要盯着波形上的跳变沿,就能推算出位周期,进而推算出每一位的边界在哪。这一点对于长线、低成本、双端都是自己写固件的场合,几乎是量身定做。

代价也很明确:它把信号的带宽需求翻了一倍。这个后面会算。工程上所有的选择都是在拿代价换收益,关键是要算清楚这笔账划不划算。

1.2 曼彻斯特编码到底解决了哪三件事

我把它的作用拆成三条,这样记忆起来不容易混。

第一条是消除直流分量。如果直接传 NRZ(不归零码),连续发送一长串 0 或者一长串 1,线上电平会长时间保持在一个固定值上。这时候变压器耦合就传不过去了(变压器不传直流),交流耦合的接收端也会因为耦合电容的充放电而让基线发生偏移,等这一串走完,基线已经漂到不知道哪里去了,后面几个比特直接判错。曼彻斯特编码强制每个比特周期内必须有一次跳变,高低电平出现的概率基本均衡,平均值稳定在中间电平附近,直流分量被压得很小,交流耦合的电路就能正常工作。

第二条是自带时钟信息。每比特中间那个跳变沿,就是接收端用来对齐节拍的"鼓点"。哪怕收发两端的晶振差个百分之几,接收端也能每个比特重新对齐一次,误差不会累积。这跟异步串口那种"一帧只对齐一次、靠内部时钟往后数"的方式完全不同,鲁棒性差着量级。

第三条是实现成本低。它的编解码逻辑非常简单,编码就是把一位变成两位,解码就是测边沿间隔然后查表,不需要任何浮点、不需要大缓冲区,一个 F103 的定时器和 DMA 就能完全搞定,CPU 几乎不参与。

我见过有人把这三条总结成"抗直流、抗漂移、易实现"。现场那三米线、地电位乱跳的工况,恰好把前两条的痛点全占了,所以换编码方式之后效果立竿见影。

1.3 一个非典型的参照:125kHz 门禁卡

再举个离大家更近的例子。很多 125kHz 的低频门禁卡、动物耳标、工业标签,用的就是曼彻斯特编码。卡片本身没有电池,靠读卡器辐射出来的载波取电,能量非常有限,不可能再维持一个稳定的本地振荡器去做精确采样。它的做法就是从载波上把时钟"抠"出来——曼彻斯特码的跳变沿正好提供了这个时钟,用锁相环或者简单的边沿检测就能恢复。这个场景把"自同步"的价值体现得淋漓尽致:越是没有稳定时钟、越是能量紧张的系统,越依赖把时钟藏进数据里。

值得一提的是,IEEE 802.3 定义的经典 10BASE-T 以太网也用的是曼彻斯特编码,10 Mbps 的数据率对应 20 MBd 的符号率,信号频谱从 0 一直延伸到 20 MHz 附近。这个例子很好记:波特率是比特率的两倍,这是我见过最多人算错的一个参数。

2. 跳变规则背后的逻辑:两种约定与一张编码表

2.1 IEEE 802.3 与 G.E. Thomas:同一件事的两种极性

曼彻斯特编码的核心规则只有一句话:每个比特周期被分成前后两个相等的小段(叫半位,half-bit),前段和后段的电平必须相反。这样一来,比特周期的正中间一定存在一次跳变。至于这次跳变是从高到低还是从低到高,就产生了两种主流约定。

约定名称逻辑 0逻辑 1中间跳变方向常见使用场景
IEEE 802.3前半位高、后半位低前半位低、后半位高0 为下降沿,1 为上升沿经典以太网、部分工业总线
G.E. Thomas前半位低、后半位高前半位高、后半位低0 为上升沿,1 为下降沿门禁卡、部分低速遥控、教学示例

两套约定在数学上完全等价,信息量一样,抗干扰能力一样,区别只是"把上升沿定义成 0 还是定义成 1"。真正的问题是:收发双方必须用同一套约定。我见过不止一次,发送端按 Thomas 写,接收端按 802.3 解,结果收到的数据全部取反,而且因为帧头校验也没过,排查了半天以为是硬件问题。

有个很实用的记忆法:把"0"想象成一个"从高往下掉"的动作,802.3 就是这么定的,字母 O 的形状像个圈,从上面掉下来;而"1"是往上顶的。你也可以干脆反过来记,但一定要在自己项目里写死一条,并且在代码注释里标清楚,别让下一个人接手的时候猜。

2.2 逐位推演:从比特到电平序列

光看表还是抽象的,我们拿一个具体字节走一遍。假设按 G.E. Thomas 约定,逻辑 0 编码成"低高"(记作 01),逻辑 1 编码成"高低"(记作 10)。这里我先约定:写"01"表示前半位低、后半位高。

要发送一个字节 0xB5,二进制是 1011 0101,从最高位开始:

位序数据位编码结果累计电平序列
b711010
b60011001
b5110100110
b411010011010
b30011001101001
b2110100110100110
b100110011010011001
b01101001101001100110

最终在线上的电平序列是1 0 0 1 1 0 1 0 0 1 1 0 0 1 1 0,一共 16 个半位。你可以注意两点:第一,任意两个相邻位之间,边界处的电平可能相同也可能不同,这取决于前一位的后半位和后一位的前半位;第二,每个位周期的中间一定有一次跳变,这就是接收端的对齐依据。

再用 IEEE 802.3 约定(0 记作 10,1 记作 01)编同一个字节,结果就变成0 1 1 0 0 1 0 1 1 0 0 1 1 0 0 1,刚好整个取反。这一点很关键:两种约定的输出是互补的。如果你发现解码出来的数据总是和发送的相反,八成就是极性搞错了。

2.3 波特率是比特率的两倍,这笔账怎么算

很多人第一次算这个会绕进去。我们来算一遍。

假设数据率是 1 Mbps,也就是每秒传 1,000,000 个比特。每个比特被拆成两个半位,所以每秒需要在线路上输出 2,000,000 个电平符号。也就是说符号率(波特率)是 2 MBd。

如果用一个定时器以固定频率翻转 GPIO 来生成这个波形,那么定时器的更新频率应该是多少?答案就是 2 MHz,也就是每个半位触发一次,而不是每个比特触发一次。我最初写代码的时候就是这里栽的跟头:定时器配了 1 MHz,结果波形出来速度只有一半,接收端解出来一堆错。

带宽方面,理论上曼彻斯特信号的功率谱主瓣宽度大约是比特率的两倍,跟 2 MBd 的符号率对得上。所以 1 Mbps 的数据率,你至少得准备 2 MHz 甚至更宽的通道带宽。线材的截止频率、驱动器的压摆率、接收端的采样带宽,都得按这个来选。这也是它相比 NRZ 最明显的劣势:同样一根线,用 NRZ 能跑 2 Mbps,用曼彻斯特只能跑 1 Mbps。

换算关系就一条公式:

符号率(Bd)= 比特率(bps)× 2 半位周期(s)= 1 / 符号率 = 1 / (比特率 × 2)

举例:目标 500 kbps,半位周期 = 1 / 1,000,000 = 1 微秒;目标 1 Mbps,半位周期 = 0.5 微秒。这些数字后面算定时器参数的时候会直接用到。

3. 和其他线路码对比:什么时候该用它

3.1 NRZ、曼彻斯特、差分曼彻斯特、4B/5B 横向对照

选编码方式之前,把几个常见选项放在一起看会更清楚。这张表是我自己在选型时用的,整理出来供参考。

编码方式每比特符号数是否含时钟信息直流分量带宽效率典型应用
NRZ1无差(长串 0/1 时严重)高UART、SPI、内部总线
曼彻斯特2强(每比特必跳变)好低(0.5 bps/Hz)10BASE-T、低频卡、工业短距
差分曼彻斯特2强好低令牌环网、部分遥控
4B/5B1.25中(靠编码保证跳变密度)好中高(0.8 bps/Hz)100BASE-TX、FDDI

从表里能看出来,曼彻斯特的定位非常清晰:它在"带宽效率"这一栏几乎垫底,但在"时钟信息"和"直流分量"两栏都是满分。所以它适合的场景是——线路带宽够用、但是对同步和抗漂移要求高、同时又不想加额外时钟线或者贵收发器的地方。反过来,如果你有的是带宽、缺的是速率,比如要跑几十兆,那 4B/5B 或者更高级的编码才是正解。

特别提醒:不要因为"曼彻斯特看起来简单"就在高速率场合硬上。1 Mbps 数据率要占 2 MHz 带宽,10 Mbps 就要占 20 MHz,而 STM32 的 GPIO 翻转速率、线上传输的边沿质量、接收端的采样率都会跟着吃紧。我个人的经验界线是:在普通 FR4 板加一两米排线的条件下,用定时器加 DMA 直接发波形,1 Mbps 到 2 Mbps 数据率是比较舒服的区间,再往上就该考虑换方案了。

3.2 差分曼彻斯特多出来的那一点好处

差分曼彻斯特值得单独提一句,因为它的设计思路很巧妙。它同样保证每个比特周期中间有一次跳变,但比特值的判定不再看跳变方向,而是看位起始处有没有跳变:位起始处有跳变代表某一种值,没有跳变代表另一种值。

这样做的好处是极性无关。也就是说,如果你把两条信号线接反了,或者整个通道的极性被反相了,解码结果依然正确——因为判定依据是"跳变的有无",而不是"跳变的方向"。这在接插件可能插反、或者经过一级反相放大的场合非常有用。

代价是解码逻辑稍微复杂一点,需要同时盯着位中间的跳变和位起始的跳变,状态机多一个分支。如果你的通道极性是确定的、不会变,用普通曼彻斯特就够了;如果存在极性不确定的风险,差分曼彻斯特值那点额外的代码量。我在另一个项目里因为接插件没有防呆、又懒得改硬件,就用差分曼彻斯特兜住了这个坑,实测挺省心。

4. STM32 上四种实现路线选型

4.1 软件 GPIO 翻转:最土但最快上手

最简单粗暴的做法,就是写个循环,按半位周期依次设置 GPIO 输出高低电平,中间用延时或者定时器等待。代码大概十几行,五分钟就能跑起来。

它的优点是直观、不用配 DMA、不用查手册对通道映射,改一个电平就能在示波器上看到变化,非常适合第一版验证协议逻辑。我调编码正确性的时候,永远先用这个版本跑一遍,确认波形对了,再去优化。

缺点同样明显。CPU 全程被占死,几百 kbps 以上基本就没法兼顾别的事了;延时精度受中断影响,一旦有别的中断插进来,位周期就被拉长,波形抖动明显;而且它没法产生连续的长数据流,发到一半被中断打断,接收端就废了。

我的建议是把它当"调试脚手架",而不是最终方案。协议逻辑用软件版跑通、用示波器抓波形确认无误之后,再迁移到 DMA 版本,这样排查问题的维度少一个,效率高很多。

4.2 定时器 PWM 加 DMA 写 BSRR:推荐方案

这是我最终用的方案,也是我认为在 STM32 上做曼彻斯特编码最舒服的一条路。核心思路是这样的:

让一个定时器以"半位周期"为间隔产生更新事件,每次更新事件触发一次 DMA 请求,DMA 把一个预先算好的值写进 GPIO 的 BSRR 寄存器。BSRR 的低 16 位写 1 表示把对应引脚置高,高 16 位写 1 表示把对应引脚拉低。所以我们只需要准备一个数组,数组里每个元素要么是"置高"的值,要么是"拉低"的值,DMA 循环着往外搬就行了。

这样做有几个好处:CPU 零参与,一旦启动,只要数据备好,波形会一直连续输出;抖动极小,因为每次电平变化的时刻由定时器硬件决定,跟中断响应没关系;速度快,F103 在 72 MHz 主频下,半位周期做到 0.5 微秒完全没有压力,也就是 1 Mbps 数据率。

唯一需要注意的是 DMA 的搬运节奏必须和定时器严格同步。做法是把定时器的更新事件配成 DMA 的触发源(不同系列的映射方式略有差异,F1 系列用 TIM_DMACmd 打开对应通道的 DMA 请求,F4 系列在新版 HAL 里通过 TIM 的 DMA Burst 或者简单地把 DMA 通道挂到 TIMx_UP 上)。具体的映射关系一定要查对应型号的参考手册里"DMA 请求映射"那张表,我见过有人凭印象配错通道,结果波形完全不出来,查了两小时。

4.3 SPI 主机加查表:速率高、CPU 占用低

还有一条很聪明的路:把每个数据位映射成两个比特,然后用 SPI 以两倍于目标数据率的时钟把这些比特打出去。比如目标数据率 1 Mbps,就把 SPI 时钟配成 2 MHz,数据随便发,SPI 硬件会自动按位输出,每个比特的时长是 0.5 微秒,正好是我们要的半位。

编码方式就是查表。把每个输入字节拆成两个半字节,每个半字节查一次表得到一个字节,两个字节拼起来就是最终的输出。这是典型的"以空间换时间":表占 16 字节的 Flash,但编码速度是常数级的,一个字节查两次表就完了。

/* 按 G.E. Thomas 约定,0 -> 01,1 -> 10 */ /* 输入 4 位,输出 8 位,高位先出 */ static const uint8_t man_table[16] = { 0x55, 0x56, 0x59, 0x5A, 0x65, 0x66, 0x69, 0x6A, 0x95, 0x96, 0x99, 0x9A, 0xA5, 0xA6, 0xA9, 0xAA }; /* 把一个字节编码成两个字节,返回长度固定为 2 */ void man_encode_byte(uint8_t in, uint8_t *out) { out[0] = man_table[(in >> 4) & 0x0F]; out[1] = man_table[in & 0x0F]; }

注意这张表是按"0 记作 01、1 记作 10"生成的,如果你要用 IEEE 802.3 约定,把每个值按位取反就行了,或者直接用互补表,思路一样。

SPI 方案的限制在于,它的位序、空闲极性、时钟相位都必须和接收端对得上,而且 SPI 是主机驱动的,没法像定时器那样灵活地在帧与帧之间插入自定义的前导码(其实可以,插入到缓冲区里就行)。另外,SPI 的片选信号在这里没用,得关掉或者忽略。

4.4 硬件异或加载波:把 CPU 彻底解放出来

最后一条路是纯硬件的:用一个异或门(比如 74HC86),一路输入接定时器产生的方波载波,另一路接数据。载波频率是两倍的比特率,数据在每个比特周期保持稳定,异或之后输出的就是标准的曼彻斯特波形。

原理很简单:载波本身每个半位翻转一次,数据位为 0 时,异或结果等于载波(低高);数据位为 1 时,异或结果是载波的反相(高低)。这正好就是我们前面推的编码规则。想要切换约定,反过来接一下数据的极性就行。

这个方案的好处是,主控只需要用 UART 或者 SPI 按普通方式把数据发出来,异或门负责"加时钟"这件事,主控侧几乎没有任何额外负担,也不需要 DMA 配合定时器。代价是板子上要多一个芯片、多占两个 GPIO,而且载波和数据之间的相位关系必须严格对齐——如果数据的跳变时刻恰好落在载波的边沿附近,异或输出会出现毛刺。解决办法是让数据在载波的固定相位上更新,比如用载波的下降沿去锁存数据,用一个 D 触发器就能搞定。

什么时候值得上这套硬件?我自己的判断是:主控资源紧张(比如跑着复杂算法,DMA 和定时器都得留给别的外设),或者速率要求超出软件方案的舒适区,又或者要做多路并行发送。否则一颗几毛钱的逻辑芯片加上两个额外的布线,对大多数项目来说是得不偿失的。

5. 参数计算与核心代码落地

5.1 定时器时钟树与实际 ARR 计算

参数计算这一步,是我见过出错最多的地方,根源都在时钟树上。我们拿 STM32F103C8T6 举个完整的例子,把每一步都算清楚。

F103 的定时器分两组:TIM1 挂在 APB2 上,TIM2 到 TIM4 挂在 APB1 上。这里有个很容易被忽略的规则:当 APB 预分频系数不为 1 时,挂在该总线上的定时器时钟会被自动乘以 2。默认配置下,APB1 是 36 MHz,APB2 是 72 MHz,但 TIM2 到 TIM4 的实际时钟是 36 × 2 = 72 MHz,TIM1 的时钟是 72 MHz。所以两组定时器的时钟其实都是 72 MHz,这个"乘以 2"的规则一定要记住,否则算出来的频率会差整整一倍。

现在要生成 1 Mbps 的曼彻斯特码,半位周期是 0.5 微秒。定时器计数频率 72 MHz,那么:

计算项公式结果
目标半位周期1 / (2 × 1 Mbps)0.5 μs
定时器计数频率72 MHz72 MHz
需要的计数值0.5 μs × 72 MHz36
预分频 PSC取 0(不分频)0
自动重装 ARR36 - 135

所以 PSC = 0,ARR = 35,更新事件频率就是 72 / 36 = 2 MHz,正好对应 1 Mbps 的数据率。这个数字很干净,实际用的时候直接把 35 写进去。

如果要跑 500 kbps,半位周期就是 1 微秒,ARR = 72 - 1 = 71。如果要跑 250 kbps,半位周期 2 微秒,ARR = 144 - 1 = 143。都能直接心算出来。

再提醒一个细节:如果目标速率不是 72 MHz 的整数分频,比如想要 1.2 Mbps,半位周期 416.67 纳秒,ARR 就得取 30,实际半位周期是 31/72 ≈ 430.6 纳秒,对应数据率约 1.161 Mbps,存在 3% 多的误差。这个误差对曼彻斯特解码是可接受的(后面会讲容差),但如果你的系统里还有其他时钟依赖,就要评估一下。降低误差的办法是提高定时器时钟或者换主频,比如把系统跑到 96 MHz 或 168 MHz。

5.2 编码缓冲区的组织方式

DMA 往外搬的那个数组,元素内容要写成 BSRR 的写入值。假设信号脚是 PA5,那么:

  • 置高:(1UL << 5),也就是 0x00000020
  • 拉低:(1UL << 21),也就是 0x00200000

我们可以定义两个宏,代码可读性好很多:

#define PIN_TX 5 #define MAN_HIGH (1UL << PIN_TX) #define MAN_LOW (1UL << (PIN_TX + 16))

然后从编码后的两个字节展开成 16 个 BSRR 值。展开函数这样写:

static uint32_t man_buf[MAN_MAX_BITS * 2]; /* 把编码后的字节流展开成 BSRR 写入序列 */ static uint32_t *man_expand(const uint8_t *enc, uint32_t enc_len) { uint32_t *p = man_buf; for (uint32_t i = 0; i < enc_len; i++) { uint8_t b = enc[i]; for (int k = 7; k >= 0; k--) { *p++ = (b & (1u << k)) ? MAN_HIGH : MAN_LOW; } } return p; }

这里enc是上一节的表查出来的编码结果,每个比特在enc里是一个比特,展开之后每个比特变成一个 BSRR 条目,DMA 每个更新事件搬一个,节奏正好是半位周期。

关于缓冲区大小,算一下就知道:一个 16 字节的帧,编码后是 32 字节,展开后是 256 个 uint32_t,也就是 1 KB 的 RAM。F103C8 有 20 KB RAM,完全够用。如果你要发更长的帧,记得把 DMA 的传输长度和缓冲区都对应放大,并且注意 DMA 循环模式下的缓冲区地址对齐。

启动的时候,先把 DMA 配成循环模式,源地址指向 man_buf,目标地址写&GPIOA->BSRR,数据宽度都是 32 位,每一项搬运长度为展开后的总数。然后再打开定时器的 DMA 请求和更新中断(如果不需要中断就只开 DMA 请求),最后启动定时器。顺序很重要:先备好数据再开定时器,反过来会导致前几个半位输出错误的电平。

5.3 前导码与帧同步的设计

纯曼彻斯特码流本身没有帧边界的概念,接收端一上电,看到的就是一连串电平跳变,它不知道哪两个半位属于同一个比特,也不知道字节从哪里开始。所以必须自己定义一个同步机制。

我的做法是在每帧数据前面加一段固定的前导码,比如 8 个字节的 0x55(二进制 0101 0101)。选 0x55 是有讲究的:它在曼彻斯特编码之后,电平序列会变成规律性极强的交替跳变,接收端很容易识别出这个特征。有些传统协议会用 0xAA 或者 0x7E,也是同样的道理。

前导码之后加一个同步字,比如 0x7E,用来做帧定界。接收端先用前导码把节拍锁定,再用同步字确认字节边界,然后才开始正式接收数据。数据后面跟一个校验字段,CRC16 或者简单的累加和都行,用来判断这一帧有没有出错。

这里有个经验值:前导码长度不要少于 4 个字节。太短了接收端的位对齐算法还没来得及收敛;太长了又浪费带宽。我一般用 4 到 8 个字节,视线路质量而定。线路差的场合,把前导码加到 8 甚至 16 个字节,能让接收算法有更多机会锁定。

6. 接收端解码:同步、采样与容错

6.1 边沿间隔解码的核心思路

接收端的任务,是从一串电平跳变中恢复出比特。核心逻辑可以概括成一句话:测两个相邻边沿之间的时间间隔。

一个比特周期 T 被分成两个半位。如果两个相邻边沿的间隔是 T/2,说明这是位中间的跳变,而且这个位和它相邻的位在边界处没有跳变;如果间隔是 T,说明这个边沿是从位中间跳变跳到相邻位的边界跳变,或者反过来,此时边界处发生了跳变。更直接的做法是:找到位中间的跳变之后,在距离它 T/2 的位置去采样电平,采样到的值就是这一位的数据(按约定映射)。

具体实现上,我用的是一套状态机:

typedef enum { RX_WAIT_EDGE, /* 等待第一个边沿 */ RX_SYNC_HALF, /* 等待半个位周期后的跳变 */ RX_DECODE, /* 正常解码状态 */ } rx_state_t; /* 定时器以 4 倍符号率自由运行,cnt 是捕获到的计数值 */ static void on_edge_capture(uint32_t cnt) { static uint32_t last_cnt = 0; static rx_state_t st = RX_WAIT_EDGE; uint32_t delta = (cnt - last_cnt) & 0xFFFF; uint32_t half = HALF_TICK; /* 一个半位对应的计数数 */ /* 容差判断:允许 ±25% 的偏差 */ if (st == RX_DECODE) { if (is_near(delta, half, TOL)) { /* 正常间距,继续下一个比特 */ push_bit_from_level(current_level); } else if (is_near(delta, half * 2, TOL)) { /* 间隔翻倍,说明边界处没有跳变,补一个位 */ push_bit_from_level(current_level); push_bit_from_level(current_level); } else { /* 超差,判定同步丢失,回到等待状态 */ st = RX_WAIT_EDGE; } } last_cnt = cnt; }

这段是伪代码,但把关键判断都体现了。真实代码里会更啰嗦一些,因为要处理位计数、字节组装、CRC 校验这些东西,但骨架就是这样。

容差设 ±25% 是我实测下来比较稳的值。太小了,晶振偏差和边沿抖动会让它频繁丢同步;太大了,相邻的不同间隔会分不开。如果你的收发两端用的是不同的晶振,把容差往大放一点,比如 ±30%,但要保证 T/2 和 T 的判定区间不重叠,否则就会出现误判。

6.2 用输入捕获加 DMA 把边沿时间戳抓下来

上面那套逻辑,如何高效地获取边沿时间戳?最简单的是外部中断加定时器读取计数器。但中断频率等于边沿频率,1 Mbps 数据率下边沿频率最高可达 2 MHz,中断根本扛不住,CPU 会被打爆。

正确做法是用定时器的输入捕获配合 DMA。思路是:让一个高频定时器(比如 8 倍或 16 倍符号率)自由运行,配置一个通道为输入捕获模式,捕获到的计数值通过 DMA 自动搬到环形缓冲区里,等缓冲区攒够一批再统一处理。这样中断频率降到了 DMA 传输完成中断的级别,CPU 负担很小。

这里有个小技巧:定时器的计数位数要够。16 位定时器在 8 倍符号率下,2 MHz 计数频率只能记 32 毫秒就溢出了。如果两帧之间的边沿间隔可能超过这个时间(比如帧与帧之间有空闲),溢出判断就很重要。解决办法有三个:一是降低过采样率,二是用 32 位定时器(F4 及以上有 TIM2 和 TIM5 是 32 位的),三是在捕获中断里累加溢出次数。我一般直接选 32 位定时器,省心。

过采样率的选择也值得说一句。理论上 4 倍符号率就够了(因为只需要区分 T/2 和 T 两种间隔),但实际中为了抗抖动,我一般用 8 倍。8 倍符号率意味着 1 Mbps 数据率下定时器要跑 16 MHz,F103 完全没问题;如果是 10 Mbps 数据率,就要 160 MHz 的计数频率,那就得用 F4 或者换方案了。

6.3 接收算法的鲁棒性设计

前面说的都是理想情况。实际线路上,边沿会抖动、会有毛刺、会有偶发的干扰脉冲,接收算法必须能扛住这些。我总结了三条有用的措施。

第一是边沿滤波。输入捕获之前,先用定时器或 GPIO 的滤波功能把窄于某个宽度的脉冲滤掉。STM32 的输入捕获通道可以配置数字滤波器,用几个采样点一致性判断来去毛刺,这个功能非常值得用上,配置一两行代码的事。

第二是超时重置。如果两个边沿之间的间隔超过了若干个位周期,说明这一帧已经结束了,或者出现了异常,此时应该把状态机复位到等待状态,准备接收下一帧。这个阈值的设定要结合你的帧结构,一般在帧尾之后留出二三十个位周期的空闲,超过就认为帧结束。

第三是校验加丢弃。收到一帧之后算 CRC,不对就丢掉。不要试图去"纠错",曼彻斯特的误码通常是成串出现的(干扰脉冲会打乱好几个位),单比特纠错帮不上忙,重传反而是更靠谱的方案。

我在那个三米线的项目里,一开始没加边沿滤波,结果现场只要有设备启停,接收端就会多出几个虚假边沿,解出来的数据全是垃圾。加上滤波之后,误码率直接降到了零。所以这条别省。

7. 常见问题与排查技巧实录

调试过程中踩的坑,我整理成一张速查表,遇到问题按这个顺序排查,能省不少时间。

现象可能原因排查方法解决方式
波形频率正好是目标的一半定时器时钟源或分频算错示波器测半位周期,与公式对比检查 APB 分频与定时器倍频规则,核对 ARR
波形频率正好是目标的两倍波特率与比特率混淆确认发送的是"每半位一个更新事件"修正定时器频率为 2 倍比特率
解码数据全部取反IEEE 与 Thomas 约定不一致抓一帧已知数据对比编码表统一约定,或在接收端取反
前几个比特总是错定时器启动早于数据搬运查看首段波形是否与编址不符先备好 DMA 数据再启动定时器
长线传输误码率高边沿被磨圆、阻抗不匹配观察远端波形上升沿时间减小驱动电流、串接匹配电阻、降低速率
偶发虚假边沿导致乱码干扰脉冲被捕获示波器看是否有多余窄脉冲打开输入捕获数字滤波
帧与帧之间丢同步空闲时间超出定时器计数范围检查空闲间隔与定时器溢出换 32 位定时器或累加溢出计数
DMA 只搬了一轮就停循环模式未开或长度配错检查 DMA 模式位与传输长度配置循环模式,长度设为展开后总数
高数据率下波形抖动GPIO 翻转速率不足查芯片手册的最大 GPIO 翻转频率降低速率或改用带高速输出能力的外设

除了表格里这些,还有几个表格里不好表达的经验。

一个是测波形一定要测最远端。我在板子上的测试点看到波形方方正正,觉得没问题,结果拉到接收端一量,边沿已经变成了弧形,幅度也衰减了。原因是线材是低通的,而且没有做阻抗匹配。后来在发送端串了一个 33 欧的电阻,接收端并联了一个小电容做补偿,波形才恢复过来。这个教训是:测点选在生产端等于没测。

另一个是先跑低速,再往上加。我习惯先用 10 kbps 把整条链路跑通,确认编解码逻辑、同步机制、CRC 都对,然后再一档一档往上加,每次加一倍,每次都用误码率统计确认。这样出问题的时候可以立刻定位到是哪个速率段开始崩的,避免在高速下跟一堆问题纠缠。

第三个是不要迷信连续测试。连续跑几小时不报错不代表稳定,因为现场干扰往往是间歇性的、跟别的设备联动的。我的做法是故意在旁边制造干扰源(开关大功率设备、用电机启停),观察误码率变化。这个方法比较土,但确实找出了好几个只在特定条件下才出现的问题。

8. 我在这个项目里最后用的那点东西

代码量其实不大。发送端是一个 DMA 展开函数加一个定时器初始化,接收端是一个输入捕获加 DMA 的环形缓冲,再加一个状态机。加起来也就三四百行,但每行都踩过坑。

回头看,最有价值的其实不是编码本身,而是两个认识。第一个是:把时钟信息嵌进数据,在很多低成本场景里比堆硬件划算得多。这句话不只适用于曼彻斯特编码,也适用于所有自同步的编码方案。第二个是:参数计算一定要落到具体的时钟树上,从系统时钟到 APB 分频到定时器倍频到 ARR,每一步都要自己算一遍,别抄别人的数字——因为别人的主频可能和你不一样,抄过来就是错的。

最后再分享一个我自己在用的调试小工具:写一段最简单的接收代码,不做任何 CRC 校验,只把解出来的原始字节通过串口打印出来。配合发送端发固定的测试图案(比如 0x55、0xAA、0x0F、0xF0 这几个),一眼就能看出是哪一位、哪一段出了问题。这个土办法在定位"极性反了""边界错了""丢了一位"这三类问题上,比任何逻辑分析仪都快。等这几个图案都对了,再上正式协议和 CRC,基本就是一次通过。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 5:49:26

微信开源WeKnora:深度文档理解与本地部署实战

微信团队这次开源 WeKnora&#xff0c;说实话我第一反应是有点意外的。腾讯系的开源项目向来克制&#xff0c;而知识库RAG 这个赛道已经挤满了 Dify、RAGFlow、FastGPT 这些玩家&#xff0c;微信这时候入场&#xff0c;手里到底攥着什么牌&#xff1f;我把项目拉下来在本机跑了…

作者头像 李华
网站建设 2026/10/2 5:49:15

Manus 2.0与Cue智能体:云原生AI终端的技术演进

我无法根据提供的输入内容生成符合要求的博文。原因如下&#xff1a;输入中缺失关键信息&#xff1a;项目正文为空&#xff08;项目正文: [通常比较零散、不完整的原始描述&#xff0c;可是任意领域内容]一栏完全空白&#xff09;&#xff0c;关键词仅列出Manus,Manus 2.0,Cue,…

作者头像 李华
网站建设 2026/10/2 5:47:48

Excel可视化分析全攻略:从图表选型到实操避坑指南

一张表能说明白的事&#xff0c;何必开三个会。做数据分析最怕的不是数据多&#xff0c;而是数据摆在那没人看得懂。Excel可视化分析这件事&#xff0c;说难不难&#xff0c;说简单也有一堆细节坑。我这几年用Excel做报表、做汇报、做业务复盘&#xff0c;柱形图、条形图、饼图…

作者头像 李华
网站建设 2026/10/2 5:47:42

腾讯WeKnora深度实践:Agentic RAG知识库部署与调优指南

1. 为什么我花了两周时间折腾 WeKnora第一次看到 WeKnora 这个名字&#xff0c;是在一个做企业知识管理的群里。有人甩了张截图&#xff0c;说腾讯微信团队开源了一个 AI 知识库项目&#xff0c;能直接把一堆 PDF、Word、Markdown 丢进去&#xff0c;然后用自然语言问它问题&am…

作者头像 李华
网站建设 2026/10/2 5:47:41

零空间(Null Space)是什么?从矩阵映射到机器学习盲区

矩阵这玩意儿吧&#xff0c;我刚学的时候也觉得它就是一堆数排成矩形&#xff0c;用来解方程组的。直到后来做数据降维、看特征值、搞深度学习里的各种分解&#xff0c;才发现矩阵的本质是个“映射”——它把一个向量空间的点搬到另一个空间去。而在这个视角下&#xff0c;有个…

作者头像 李华