拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于STM32库函数的EM4100曼彻斯特解码详解

基于STM32库函数的EM4100曼彻斯特解码详解

做读卡器项目时,最绕不开的一个环节就是把125kHz的EM4100卡号从一堆波形里“抠”出来。EM4100本身不带解码芯片,它只输出经曼彻斯特编码后的数据流,真正要读懂卡号,得靠MCU自己解调。我之前用寄存器版本写过一版,可读性很差,后来改用STM32标准外设库(也就是常见的库函数版本)重写,代码结构清楚了不少,排查问题也方便。这篇文章就从协议、信号、代码、坑点四个维度,把我自己跑通的这套EM4100解码方案完整拆开讲一遍,项目用的是STM32F103C8T6,核心思路是定时采样+软件状态机,不需要额外硬件解码芯片,每张卡的识别流程完全可控。

1. 解码方案的整体设计与思路拆解

1.1 项目背景与硬性需求

EM4100是低频RFID领域非常常见的一款只读芯片,工作频率125kHz,很多门禁卡、考勤卡、动物耳标里用的都是它。它的特点是数据率很低,典型速度约1.9kbps,但抗干扰能力强,读取距离也不算差。做这类项目时,我们最关心的其实只有一件事:把标签里的卡号稳定、准确地读出来。

拆解这个需求后,实际要解决的是三个子问题。

第一,物理层信号怎么进MCU。125kHz的载波经过天线谐振、检波、放大后,最终在模块输出端得到的是一串曼彻斯特编码的数字电平信号。这串信号可以直接接到MCU的GPIO上,但采样时机和电平判断必须足够精确,否则后续解码全是错的。

第二,协议层怎么对齐帧。EM4100在传输数据前会发送一段固定的引导头,接收端必须依靠这段引导头来找到数据起始位置。这个对齐过程如果靠简单的延时等待,很容易被噪声干扰;如果靠边沿捕获,又需要额外管理定时器资源。

第三,解码后的数据怎么校验。EM4100的数据区带有行校验和列校验,不校验直接拿来当卡号用,碰到个别bit翻转的情况就会读到错误卡号。这在门禁场景里是不可接受的。

我的方案选型是:GPIO读取+定时器中断采样,用软件状态机完成bit恢复、帧同步、数据校验和卡号输出。整套逻辑跑在库函数封装的外设接口上,不依赖任何第三方库,也不涉及寄存器直接操作。这样做的好处是代码可读性强,后续想移植到其他STM32型号,只需要改引脚和定时器配置即可。

1.2 曼彻斯特编码的本质与采样思路

曼彻斯特编码是一种自带时钟的编码方式,它把时钟信号和数据信号叠加在一起传输。每个bit的持续时间固定,并在该bit的中间位置一定会发生一次电平跳变。跳变方向代表了该bit的值:在半位时刻从低电平跳向高电平表示逻辑0,从高电平跳向低电平表示逻辑1。

这种编码最大的优点就是接收端不需要独立的时钟同步信号,只要能在正确的时间点观察电平变化方向,就能恢复数据。缺点也明显:对采样时刻的精度要求比较高,如果采样点正好落在跳变沿附近,读到的电平就是不确定的。

针对EM4100的曼彻斯特编码,我最终采用的是“半位间隔连续采样+相邻样本比较”的策略。每个bit周期内采样两次,两次样本如果在同一bit内,必然相反;如果出现在相邻bit的交界处,可能相同也可能相反。通过状态机对相同/相反两种情况进行分类处理,就能在不知道起始相位的情况下恢复出完整bit流。

1.3 为什么选库函数版本而不是寄存器或HAL

这个项目最开始的版本是用寄存器直接操作外设,速度快,但有一个很实际的问题:EM4100解码涉及到定时器初始化、GPIO模式配置、中断优先级设置等多个外设的协同,寄存器代码每次都要翻参考手册核对位定义,调试时特别耗精力。如果中途换一个芯片型号,几乎等于重写一遍。

HAL库当然也可以,但HAL库的抽象层比较厚,对于这种对时序敏感的低速解码场景,没必要引入那么多回调机制。库函数版本(标准外设库)则是个很好的平衡点:它把寄存器的配置封装成InitStruct结构体,调用流程直观,而且底层依然是直接访问寄存器,性能损耗可以忽略不计。配合Keil MDK工程,对应型号的库函数手册在网上一搜就有,遇到不清楚的函数,查手册比查寄存器快很多。

2. EM4100协议与曼彻斯特编码的对应关系

2.1 数据帧的物理结构

EM4100发送的底层数据流按bit排列,常用结构包含三大部分:引导头、有效数据区和校验区。引导头部分由一组高电平的引导码和一个同步位组成,接收端用“连续9个1+1个0”这个特征来定位数据起点。

有效数据区由10个半字节组成,每个半字节有4个bit,其中前3个bit是真正的用户数据,第4个bit是该半字节的行校验位。换句话说,10个半字节实际承载30bit有效数据,这30bit正好可以表示一个10位十进制卡号。

数据区之后是4位列校验位和1位停止位。列校验分别对10个半字节的同一位置做偶校验,停止位固定为逻辑0。整个数据帧加上引导头,按bit流顺序发送,高位在前。

在实际解码时,不能只盯着数据位,必须把行校验和列校验都检查一遍。原因很简单:低频RFID链路受环境干扰影响,偶尔会有个别bit翻转,如果不校验,可能把错误的卡号当成正确结果上报。

2.2 从物理bit到逻辑bit的映射规则

EM4100的曼彻斯特编码规则是:逻辑0对应一个bit周期内的“低-高”跳变,逻辑1对应“高-低”跳变。这里要注意,这个映射极性是由模块解调方式决定的,不同厂家的模块可能在输出端做了反相处理。

为了避免极性搞反导致所有卡号都解不出来,我在软件里做了可配置的极性开关。默认按“低-高为0,高-低为1”处理,如果发现解码结果全是错误甚至校验不过,就把极性参数反过来再试。这种设计在实战中帮了大忙,因为有些模块输出的波形确实是反的。

模块输出的是经过解调的包络信号,注意不是原始的125kHz正弦波。也就是说,引脚上看到的已经是规整的TTL电平了,只不过电平切换快慢对应曼彻斯特编码的bit含义。解码程序只需要关心电平变化,不需要处理高频载波。

2.3 校验规则与卡号提取

先看行校验。每个半字节有4个bit,前3位是数据,第4位是偶校验位,它保证这个半字节内“1”的数量是偶数。解码时,每收到一个半字节,就统计前3位数据中“1”的个数,再加上校验位,总数应为偶数,否则这一组数据就有问题。

列校验类似,10个半字节完成后,把每个半字节的第0位取出来,10个bit里“1”的个数应为偶数,第1位、第2位、第3位同理。注意列校验覆盖的也包括每组的行校验位,所以不是只对用户数据做列校验。

卡号提取时,把每组前3位数据按顺序拼接起来,得到一个30bit的数值,转成十进制就是常见的10位卡号。实际门禁系统打印出来的10位十进制数字,就是这么拼出来的。

2.4 关键时间参数计算

EM4100的载波频率是125kHz,内部将载波分频后输出数据,分频系数常见为64。算一下:

125kHz / 64 = 1953.125Hz

也就是每个bit周期约512微秒。进一步,一个bit周期内分前半位和后半位,每个半位约256微秒。

这个时间参数是整个解码程序的基准。定时器采样周期必须设成半位周期,也就是256微秒左右。设短了会重复采样同一个半位,设长了会漏掉跳变沿,两者都会导致解码失败。

考虑到晶振精度和代码执行时间,采样定时器允许有少量偏差,但最好不要超过±5%。STM32F103内部使用72MHz系统时钟时,配置定时器预分频为71,计数模式向上,自动重载值为255,中断周期正好约256微秒。这个数值在标准外设库的TIM_TimeBaseInitStruct里可以直接设置。

3. 硬件信号链路与前期准备

3.1 读卡模块输出信号形态

我使用的是市面上很常见的125kHz ID读卡模块,模块内部集成了天线驱动、检波、放大和比较整形电路。模块对外引出的DATA脚输出的是数字电平信号,空闲时为高电平或低电平(取决于模块设计),标签靠近时输出曼彻斯特编码的波形。

这种模块省去了自己搭天线的麻烦,但要注意供电电压。大部分模块是5V供电,DATA脚输出高电平也是5V,而STM32的GPIO耐压是3.3V,直接接会有风险。稳妥的做法是DATA脚串一个1kΩ电阻,再并一个3.3V稳压管到地,或者用电阻分压(如2kΩ串联,3.3kΩ到地),把电平压到3.3V逻辑范围。

如果模块输出是3.3V版本,那就简单了,直接和STM32相连即可。选购时一定看清楚规格书,别在电平匹配上翻车。

3.2 信号整形与噪声抑制

低频RFID模块的输出波形一般还可以,但靠近电机、开关电源等干扰源时,信号边缘会有抖动,甚至出现毛刺。硬件上可以在DATA脚到地之间加一个100nF的小电容,做简单的低通滤波,把高频毛刺滤掉。

注意电容不能太大,否则曼彻斯特码的边沿会被钝化,导致采样点判断出错。我实测100nF在短距离读取时没问题,但如果模块输出驱动能力弱,可以换成10nF再观察波形。

有条件的话,最好用示波器看一下DATA脚的波形,重点看跳变是否干脆、高低电平是否稳定。没有示波器也可以在STM32的GPIO上做软件统计,比如连续采样一段时间,打印出电平变化次数,辅助判断信号质量。

3.3 与STM32的电气连接

我的接法是:模块+5V接外部5V电源,模块GND与STM32共地,模块DATA经过分压后接STM32的PA0引脚。PA0同时复用为TIM2_CH1,方便后续如果需要用输入捕获法做对比验证。

软件初始化时把PA0配置为浮空输入或上拉输入。建议用上拉输入,这样当模块DATA脚悬空时,引脚电平稳定在高电平,避免悬空导致采样错误。

供电方面值得一提:125kHz模块工作时电流不大,但天线起振瞬间还是有冲击的,最好在模块电源引脚旁边加一个100μF电解电容和100nF陶瓷电容,减少对MCU供电的干扰。

3.4 串口与调试准备

解码结果最终要通过串口打印出来,所以我预留了USART1,PA9和PA10分别做TX和RX,波特率115200。调试时打开串口助手,每次读卡打印一次卡号、校验状态和时间戳。

还顺带在代码里加了一个简单的软件计数器,统计解码成功的帧数和校验失败的帧数。这个统计信息在排查问题时非常有用,能快速判断是信号质量问题还是协议对齐问题。有了这个基础,后面写解码逻辑心里就有底了。

4. 库函数版本的软件实现:核心代码与状态机

4.1 工程配置与GPIO初始化

先创建一个标准外设库工程,芯片选择STM32F103C8T6。这里用到的主要外设是GPIOA、TIM2、USART1和NVIC。GPIO初始化用库函数的GPIO_Init函数,把PA0设为输入模式,PA9设为复用推挽输出,PA10设为输入模式。

PA0配置为上拉输入,代码如下:

GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure);

GPIO_Mode_IPU对应的就是上拉输入,库函数里不需要管寄存器层面的细节,配置结构体填好,调用Init函数就完成了。USART的初始化配置同理,设置波特率、数据位、停止位和校验位,然后调用USART_Cmd使能。

4.2 定时器256微秒采样中断配置

采样中断是整个解码程序的“心跳”。TIM2在APB1时钟域,STM32F103的APB1最高36MHz,但定时器时钟会倍频到72MHz。按72MHz计算,预分频设置为71得到1MHz计数频率,自动重载值设为255,溢出周期就是256微秒。

TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Period = 255; TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); TIM_Cmd(TIM2, ENABLE);

中断服务函数里做两件事:清中断标志,然后调用解码状态机函数。

void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); EM4100_Decode_Sample(); } }

4.3 采样状态机:从电平序列恢复bit

这是整个项目最核心的部分。每次采样读一次PA0电平,记为cur,和上一次采样的电平last比较。

如果cur等于last,说明采样点跨过了bit边界,本次样本不能与上一个样本组成一个完整bit,所以把last更新为cur后直接返回,等待下一次采样。

如果cur不等于last,说明这两个样本来自同一个bit的两个半位,根据极性映射得到该bit的值。极性为正常模式时,(last==0 && cur==1)表示逻辑0,(last==1 && cur==0)表示逻辑1。

注意这里的last不是简单的“上一次GPIO电平”,而是上一次采样后更新过的“待配对半位电平”。用代码表达就是:

static uint8_t last_level = 0xFF; void EM4100_Decode_Sample(void) { uint8_t cur = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0); if (cur == last_level) { last_level = cur; return; } // cur != last_level,构成一个bit uint8_t bit_value; if (last_level == 0 && cur == 1) bit_value = 0; else bit_value = 1; last_level = cur; EM4100_Process_Bit(bit_value); }

首次采样时last_level是初始值0xFF,第一次进入函数cur与0xFF必然不等,会错误地产生一个bit。解决方法是在使能定时器前先读一次GPIO,把last_level初始化为当前电平,或者在状态机里加一个“首次样本”标志,跳过第一次配对。

4.4 帧同步与数据缓冲

从采样状态机输出的bit流里,要先找到“连续9个1和1个0”的帧头。这里有一个小技巧:利用移位寄存器维护最近10个bit,当这10个bit等于二进制1111111110时,认为帧头出现。

static uint32_t shift_reg = 0; static uint8_t frame_bits[64]; static uint8_t bit_count = 0; static uint8_t sync_done = 0; void EM4100_Process_Bit(uint8_t bit) { if (!sync_done) { shift_reg = ((shift_reg << 1) | bit) & 0x3FF; if (shift_reg == 0x3FE) { // 二进制1111111110 sync_done = 1; bit_count = 0; memset(frame_bits, 0, sizeof(frame_bits)); } } else { frame_bits[bit_count++] = bit; if (bit_count >= 45) { // 已收到数据区+校验区,开始校验 EM4100_CheckAndOutput(frame_bits); sync_done = 0; shift_reg = 0; } } }

这里接收45个bit的理由是:同步位之后是40bit的数据区(10组×4bit),加上4bit列校验和1bit停止位,总共45bit。同步位本身不存入frame_bits,避免多出一个bit导致对齐错位。

4.5 校验与卡号输出

收到45个bit后,先做行校验:每4bit一组,检查1的个数是否为偶数。再做列校验:分别统计每组同位置的1的个数,最后一位停止位必须为0。

int EM4100_CheckAndOutput(uint8_t *bits) { // 行校验 for (int i = 0; i < 10; i++) { uint8_t sum = bits[i*4] + bits[i*4+1] + bits[i*4+2] + bits[i*4+3]; if (sum % 2 != 0) return 0; } // 列校验 for (int col = 0; col < 4; col++) { uint8_t sum = 0; for (int row = 0; row < 10; row++) { sum += bits[row*4 + col]; } if (sum % 2 != 0) return 0; } // 停止位 if (bits[40 + 4] != 0) return 0; // 这里bits[40]开始是列校验位,bits[44]是停止位 // 提取卡号 uint32_t card_number = 0; for (int i = 0; i < 10; i++) { card_number = (card_number << 3) | (bits[i*4] << 2) | (bits[i*4+1] << 1) | bits[i*4+2]; } printf("Card ID: %010lu\r\n", card_number); return 1; }

停止位的位置要小心:frame_bits的前40个bit是数据区,索引0到39;第40到43是列校验,第44是停止位。上面代码里写成bits[44]才能正确对应。写成bits[44]的原因是数组索引从0开始,停止位是第45个bit,索引为44。

4.6 主循环与超时处理

主循环不需要做太多事情,主要就是定期检查解码状态机是否长时间没有数据流入,如果没有标签靠近,就把同步状态清掉,让状态机回到等待帧头状态。这个超时机制能防止上一次半截数据残留影响下一次识别。

while (1) { if (timeout_cnt > 5000) { EM4100_Reset(); timeout_cnt = 0; } }

timeout_cnt在定时器中断里累加,每256微秒加1。超过5000次意味着约1.28秒没有有效数据,此时复位状态机。做法简单但非常管用,实测下来可以避免很多假读卡情况。

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

5.1 解码不出数据?先查这几点

第一个要查的是电平极性。如果采样状态机输出的bit流和实际相反,帧头就找不到。判断方法很简单:把模块放在标签上,用示波器看DATA脚,或者写一小段测试代码连续打印GPIO电平。看到平时为低、有卡时跳高,多数是正极性;如果平时为高、有卡时跳低,就说明模块输出极性可能是反的。

第二个要查的是采样周期是否准确。TIM2的预分频和重载值如果配错,采样周期偏差太大,bit恢复就完全乱套。可以用逻辑分析仪观察GPIO翻转频率,或者直接打印定时器当前计数值验证。

第三个要查的是帧头匹配值。有些资料会用“9个1后跟1个0”描述,但是别忘了shift_reg里存的是最近10个bit,如果数据处理顺序不对,匹配值也可能差一位。遇到这种情况,把解码出的bit流用串口打印出来,对照理想波形人工看一遍,很快就能定位问题。

5.2 校验失败的典型原因

校验失败最常见的原因是采样点恰好落在跳变沿附近。曼彻斯特码的跳变沿时间很短,如果定时器中断和跳变沿重叠,读到的gpio电平可能不确定。解决方法是把采样周期微调几微秒,避开边沿,比如把自动重载值从255改成253或254。

另一个常见原因是抗干扰不足。测试时手靠近天线,或者周围有大功率开关电源,会导致个别bit翻转。硬件上加强滤波,软件上可以增加“连续多次读帧全部一致才输出”的策略。比如连续读到两帧相同的卡号再上报,误码率能显著降低。

串口打印出的卡号偶尔跳变,基本可以判定是列校验没过,因为列校验覆盖跨组的位错误。如果只是某一位偶尔错误,行校验应该能拦住,但行校验和列校验都通过的情况下还出现跳变,那就要怀疑是不是有两张卡同时进入读卡区域,造成信号混叠。

5.3 采样法之外的输入捕获方案

定时采样法在大多数情况下已经够用,但它的缺点是中断频繁,CRT代码执行时间稍长就会影响精度。如果项目对功耗有要求,或者主循环任务很重,可以考虑改用输入捕获方式。

输入捕获的思路是利用TIM2_CH1的捕获通道,捕获信号边沿,记录边沿间隔。因为曼彻斯特编码中,相邻边沿间隔要么是一个半位(约256us),要么是一个整位(约512us)。通过边沿间隔和跳变方向,也可以恢复bit流。

我当时对比过:输入捕获法在时序上更精确,中断次数少一半,但代码逻辑比采样法复杂。对于EM4100这种低频信号,采样法完全够用,我最终量产版本依然保留了采样法主线,输入捕获作为调试工具偶尔用来抓波形周期。

5.4 实测经验与参数调整心得

把整套程序跑通后,我在不同读卡距离下做了几组测试。标签贴近天线时,解码成功率接近100%;拉到20厘米以上时,偶发校验失败。这里有一个比较关键的经验:不要简单地把“解码失败”归咎于距离,很多时候是硬件电源纹波导致的。

模块和MCU共用同一个5V电源时,天线起振会让电源产生微小跌落,这种跌落通过地线耦合到MCU的ADC或GPIO,造成采样误判。我的处理方法是让模块供电和MCU供电之间加一个磁珠和电容滤波,换完之后校验失败次数明显下降。

另一个心得是调试时不要一次改动多个参数。采样周期、极性、帧头匹配值这三个参数相互影响,一次只改一个变量,能大幅减少定位成本。我一开始为了追求读卡率,同时调了采样周期和滤波电容,结果失败率反而上升,后来逐项恢复才发现是采样周期偏离过大。

6. 最后补充一个实用小技巧

做这套解码时,我一直保留着一个习惯:把解码过程中每个阶段的状态都输出到串口。帧头确认时打印一次“SYNC OK”,每收到一组数据打印一次原始bit,校验失败时打印失败原因编号。这样跑起来之后,不用猜程序内部发生了什么,波形和log一对,问题往往一眼就出来了。

如果你是第一次做EM4100,建议先别急着追求读卡距离,先把近场读卡做到100%成功,再考虑信号调理和电源优化。近场都读不稳,多半是软件状态机的逻辑有漏洞;近场稳了距离上不去,才是硬件该背的锅。

后面有时间,我打算把这套解码逻辑移植到输入捕获版本,顺便把读取距离和功耗数据一起整理出来对比。现阶段这套库函数版本已经很适合做产品原型和毕设项目了,照着上面的代码结构搭一遍,基本能跑通125kHz的曼彻斯特解码链路。

返回列表