做读卡器项目时最绕不开的一个环节就是把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个11个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 串口与调试准备解码结果最终要通过串口打印出来所以我预留了USART1PA9和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的值。极性为正常模式时(last0 cur1)表示逻辑0(last1 cur0)表示逻辑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*41] bits[i*42] bits[i*43]; 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*41] 1) | bits[i*42]; } 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的曼彻斯特解码链路。
阅读完成 · 觉得有帮助?