STM8S103自制EM4100读卡器:125kHz射频卡解码从零到串口输出
每次把罐头盖拧开第一个想到的永远是“里面到底写的是谁”。半年前我从柜底翻出几张积灰的EM4100门禁卡第一反应就是找个读卡器把卡号拆出来看看。市面上一张125kHz低频读卡器要几十块但如果用一颗STM8S103自己搭成本能压到十元以内。这个项目我在面包板上调了整整一晚最后从串口里看到稳定输出的卡号时确实有一种“协议被我一层一层撕开”的快感。整套方案不依赖RC522这类专用读卡芯片从天线驱动、载波振荡、包络检波到曼彻斯特解码和EM4100数据帧解析全是分立元件加上STM8S103的定时器硬算。核心链路是先把125kHz载波上的负载调制信号变成干净的数字方波再让单片机的输入捕获中断去测量边沿间隔还原出64位数据帧最后抽出40位ID。这个过程对理解非接触式卡的物理层和底层通信协议极其有帮助适合只想验证射频卡原理的单片机爱好者也适合想在低成本门禁、工位识别、宠物喂食器上快速上手的开发者。1. 方案选型为什么是STM8S103和125kHz1.1 几块钱的小芯片到底能不能扛住解码选单片机的第一原则永远是“够用就好”。125kHz RFID解调出来的曼彻斯特码位周期约512us数据率还不到2kbps对主频几乎没有压力。但“没压力”不代表随便拿颗51就能做得舒服核心难点在于要精准测量边沿间隔。STM8S103自带带捕获功能的定时器一次捕获能精确到微秒级中断处理后还有大量余量完全可以实时解码。这颗芯片目前市场价不到两块8KB Flash、1KB RAM、最高16MHz内部RC这些资源用在低频RFID解码上非常合适。换STM32的话性能过剩还费电换传统51又得在外围补捕获电路麻烦又不准。很多人喜欢拿它和市面上的专用读卡芯片比比如RC522。RC522确实能直接读13.56MHz的卡但它读不了125kHz的EM4100而且它内部把调制解调都做完了你只能通过SPI拿结果中间过程全黑盒。而用STM8S103做低频读卡器整个信号链露在外面每一级波形都能用示波器测到出了问题也知道去哪找这对我这种喜欢把所有细节攥在手心的人特别重要。1.2 125kHz和EM4100的底层到底长什么样低频RFID的工作方式可以理解成一个微型变压器。读卡器天线线圈产生125kHz交变磁场卡片靠近后内部谐振回路感应出电压芯片再通过改变负载阻抗去影响天线端电压幅度这个幅度变化被读卡器检波出来就是数据信号。EM4100是低频段最常见的只读ID芯片内部固化40位编码前10位一般是厂商或版本标识后32位是卡号。这种卡便宜、稳定门禁、考勤、物流、动物耳标都在用。和现在普及的13.56MHz NFC相比低频的优点是穿透性稍好、抗金属干扰能力略强缺点是通信速率慢、数据几乎明文、不支持复杂安全认证。但正因为协议公开、结构简单EM4100成了学习RFID底层机制最合适的教材。低频还有一个特点读卡距离只有几厘米这对固定工位、门禁卡应用足够也正好能让DIY方案把电路做得很紧凑。2. 读卡器硬件设计从天线到数字方波2.1 先把硬件链路想清楚再动手125kHz读卡器看起来元件很多拆开就是四件事产生载波、驱动天线、提取调制信号、整形成方波。我用的方案是NE555构成约125kHz自激振荡经过三极管推挽放大后驱动LC谐振天线天线端并联的二极管和电容组成包络检波器把卡片负载调制引起的电压起伏提取出来检波后的模拟信号再进LM393比较器整形为标准数字方波最后送入STM8S103的PB0输入捕获引脚。这里最关键的一点是进入单片机的信号必须干净不能有毛刺否则定时器捕获到的边沿全部乱掉软件再怎么写也白搭。所以比较器后面我会加一个小电容做滤波压一压高频噪声。NE555为什么好用因为125kHz这个频率正好落在555的舒适区外部电阻电容就能调成本不到五毛。当然也可以用STM8S103的定时器直接输出125kHz方波但这种做法会让芯片既发载波又解数据调试时两头都麻烦。我最终还是选择独立振荡器让单片机专心做解码排查问题也简单先确认555有输出再找后面一级的问题。2.2 天线谐振网络不是随便绕个圈就行天线线圈的电感和并联电容要谐振在125kHz附近公式就是f1/(2π√(LC))。DIY时可以先用0.2mm漆包线在5cm直径的圆柱土绕10匝再用电感表测出实际电感之后反推谐振电容。举个例子实测L200μH的话谐振电容C1/((2π×125000)^2×200μH)算下来约8.1nF实际取两个4.7nF并联就行。如果L达到1mH那电容只需要1.6nF左右。总之天线不是玄学拿公式和工具算一遍方向基本不会错。这个LC回路的Q值会直接影响读卡距离。Q值高选频特性好距离能拉远但频带变窄对温度、金属物体的变化也敏感。所以建议用可调电容在示波器上一边看天线两端波形一边调到最饱满。调好的标准很简单天线端稳定出现125kHz正弦波卡片靠近后包络检波点能看到明显的幅度起伏。如果没有起伏就算后续电路再完美也读不到卡。2.3 包络检波和比较器整形电路要点包络检波我用的是1N4148和10nF电容检波输出接到LM393的“”端“-”端接参考电压。参考电压由电位器调不能太高也不能太低太低会捡到噪声太高会把有用信号削掉。实际操作时先把卡片拿远把参考电压设成无卡时检波电压的80%左右再靠近卡片微调直到输出方波最稳定。LM393是开漏输出输出端务必接10k上拉到3.3V或5V再串个100Ω电阻、并个100pF电容进单片机PB0能明显改善边沿质量。这里有个很实际的坑STM8S103的PB0作为输入捕获引脚时如果外部信号悬空会产生随机边沿中断把状态机彻底打乱。所以引脚必须设成浮空输入同时确保比较器输出端在无卡时也能保持稳定高或低电平而不能处于三态漂移。3. EM4100协议解析与曼彻斯特解码思路3.1 64位帧结构里面不全是卡号很多刚接触EM4100的人会以为读出来的数字就是卡号这是错的。卡片上存储的是一段64位的曼彻斯特编码帧结构经常被误读。我按常用的公开协议整理成了这样帧先发9个逻辑1作为同步头接下来是10组“4位数据1位偶校验”共50位这部分包含了10位版本/厂商码和32位卡号最后再发4位列校验和1位停止位。所以完整一帧是9504164位。40位有效ID被拆成10个4位nibble每个nibble后面带一位偶校验接收端可以提前发现单bit错误。换句话说卡号不是“存了64位”而是用64位帧保护了40位有效信息。理解这一点很重要因为后面解析的时候不能简单把64位全部当卡号而是要跳过头、丢掉每组校验位再拼出ID。每个数据位时长是64个载波周期在125kHz下正好约512us。曼彻斯特编码的规则是不管数据是0还是1每个位周期中间必定有一次电平跳变高到低表示1低到高表示0。相邻两个bit如果相同位边界处会多一次沿跳变如果不同位边界处就没有跳变。这一条是所有解码算法的基石。3.2 边沿间隔法把数据流还原出来既然每位一定有中央跳变那就可以通过测量相邻两个边沿的时间差来还原原始bit序列。当一个bit与上一个不同时两个中央跳变点之间隔了一个完整位周期约512us当一个bit与上一个相同时中间还会插入一个边界跳变所以相邻两次跳变间隔只有半个位周期约256us。解码逻辑由此变得很直白捕获到一次边沿测量它与上一次边沿的时间差差在256us附近就重复输出上一个原始bit差在512us附近就把上一个原始bit取反后输出超出这个范围就认为是噪声或同步丢失清零重来。实际波形不会这么完美所以我允许±80us的容差。在STM8S103里TIM1分频到1MHz后一个计数值就是1us256us就是256个计数512us就是512个计数判断起来非常舒服。把计数器调准再把容差放开解码的稳定性比想象中好。3.3 同步、收帧与校验的软件状态机解码第一步是搜索9个连续逻辑1同步头。我维护了一个9位移位寄存器每解出一个bit就移入一位然后检查寄存器值是不是0x1FF。一旦命中说明同步头对齐了紧接着开始缓存之后的55位也就是10组×5位加上4位列校验和1位停止位。收集满55位后先检查每一组4位数据的偶校验有任何一组不一致就直接丢掉这帧。校验通过后把40位有效数据提取出来高10位作为版本/厂商码低32位作为卡号最后通过UART打印。这个状态机的好处在于不需要额外硬件同步时钟软件自己就能“咬住”帧边界。实现上我用一个add_raw_bit函数统一收数据没同步时只更新移位寄存器同步后则把bit存进数组存满一帧再交给解析函数。整个流程不复杂但状态切换的逻辑要写清楚尤其是同步成功后要立刻锁存不能继续去搜索头部不然容易收错起始位置。4. STM8S103核心代码实现4.1 开发环境和引脚规划我用的是SDCC编译器也可以用IAR for STM8。芯片选STM8S103F3P6用SWIM接口烧录。引脚分配上PB0配置为TIM1_CH1输入捕获接收比较器输出的解调方波PD4接一个LED解码成功后点亮UART1的TX默认在PD5串口输出卡号。系统时钟用内部16MHz HSI不用外部晶振。TIM1分频到1MHz这样定时器每计数一次对应1us后面处理256us、512us的边沿间隔时直接比数值就行。烧录和调试建议用ST-Link成本很低。SDCC环境下需要把标准外设库一起放进工程ST官方提供的库文件可以直接从官网下载注意芯片型号选STM8S103F3P6。如果只用代码里的寄存器操作不依赖库也能跑但GPIO和UART初始化还是用标准库写更省心下面的代码我都基于标准外设库。4.2 定时器输入捕获初始化初始化代码做了三件事把PB0设成浮空输入保证外部信号能干净进来配置TIM1工作在输入捕获模式捕获通道1映射到TI1使能CC1捕获中断并让计数器以1MHz频率计数。初始边沿设成上升沿每次中断完成边沿间隔测量后再切换CC1P比特让它变成下降沿或上升沿这样能连续跟住每一个边沿。具体代码如下。void gpio_init(void) { /* PB0 input capture, floating input, no external interrupt */ GPIO_Init(GPIOB, GPIO_PIN_0, GPIO_MODE_IN_FL_NO_IT); /* PD4 LED output push-pull */ GPIO_Init(GPIOD, GPIO_PIN_4, GPIO_MODE_OUT_PP_HIGH_FAST); } void timer1_init(void) { /* 16MHz / 16 1MHz, 一个计数值 1us */ TIM1_TimeBaseInit(15, TIM1_COUNTERMODE_UP, 0xFFFF, 0); /* CH1 mapped to TI1, rising edge initially */ TIM1_ICInit(TIM1_CHANNEL_1, TIM1_ICPOLARITY_RISING, TIM1_ICSELECTION_DIRECTTI, TIM1_ICPSC_DIV1, 0); /* enable capture compare interrupt */ TIM1_ITConfig(TIM1_IT_CC1, ENABLE); TIM1_Cmd(ENABLE); }这里TimeBaseInit的第一个参数是预分频值15实际分频比是16所以定时器时钟从16MHz变成1MHz。有人会问为什么不用更大的分频让计数器直接按512us溢出因为在32.768ms内会频繁溢出处理回绕反而麻烦。1MHz下16位计数器溢出时间是65.535ms完全够用。4.3 中断服务函数与曼彻斯特解码中断函数是整个解码的核心。每次捕获到边沿先读取CCR1寄存器算出与上一次边沿的差值再切换下一次捕获极性。接着根据差值判断当前bit是重复还是翻转差在256us附近说明当前bit和上次相同差在512us附近说明当前bit和上次相反。其他情况一律视为噪声重置状态机。完整的代码如下。INTERRUPT_HANDLER(TIM1_CAP_COM_IRQHandler, 12) { uint16_t now; uint16_t diff; if (!(TIM1_SR1 TIM1_SR1_CC1IF)) return; now TIM1_GetCapture1(); diff (uint16_t)(now - last_edge); last_edge now; /* 切换捕获边沿下一次捕获相反沿 */ if (TIM1_CCER1 TIM1_CCER1_CC1P) TIM1_CCER1 (uint8_t)~TIM1_CCER1_CC1P; else TIM1_CCER1 | TIM1_CCER1_CC1P; /* 半位周期数据位不变 */ if ((diff BIT_HALF_MIN) (diff BIT_HALF_MAX)) { add_raw_bit(last_data_bit); } /* 全位周期数据位翻转 */ else if ((diff BIT_FULL_MIN) (diff BIT_FULL_MAX)) { last_data_bit ^ 1; add_raw_bit(last_data_bit); } /* 其他都视为噪声 */ else { sync_found 0; raw_bit_pos 0; sync_shift 0; } TIM1_ClearITPendingBit(TIM1_IT_CC1); }这里需要不断更新last_edge否则回绕差值会错。STM8的16位减法在无符号下自然处理了回绕只要每次中断都及时读走就不会出错。add_raw_bit函数的实现前面说过关键是同步前用移位寄存器找9个1同步后存满55位再置标志。4.4 数据校验和卡号提取一帧55位收满后先逐组校验偶校验位。第0、1、2、3位是第一个nibble第4位是这一组的偶校验位第5~8位是第二个nibble第9位是校验位以此类推。如果所有10组校验都通过就把40位有效数据通过移位拼成一个uint64_t临时变量高10位取版本/厂商码低32位取卡号。实现如下。static void parse_frame(void) { uint64_t raw 0; uint8_t i; for (i 0; i 50; i) { if ((i % 5) 4) { uint8_t p frame_bits[i]; uint8_t n 0; uint8_t j; uint8_t calc; for (j 0; j 4; j) { n (n 1) | frame_bits[i - 4 j]; } calc (n 1) ^ ((n 1) 1) ^ ((n 2) 1) ^ ((n 3) 1); if (calc ! p) { frame_ready 0; /* 校验失败丢弃 */ return; } } else { raw (raw 1) | frame_bits[i]; } } card_version (uint16_t)((raw 30) 0x3FF); card_id (uint32_t)(raw 0xFFFFFFFF); frame_ready 0; card_ok 1; }这种四位数nibble拆开再拼的方式比一次性连续取32位要安全。因为EM4100的帧结构天然把数据切成4位一组跟着帧结构走校验逻辑也清晰。串口打印的时候我会输出Vendor: %03X Card: %08X把版本和卡号分开方便对卡。比如打印Vendor: 1F0 Card: 01234567说明这张卡的40位原始ID是0x1F001234567。4.5 主函数和串口输出主函数思路很线性初始化外设后开全局中断然后死循环里轮询frame_ready标志。一旦发现一帧收完就调用解析函数解析成功后点亮LED并通过串口输出卡号。串口波特率我用9600不管调试还是接上位机都够用。UART初始化用标准库一行搞定发送直接重定向putchar这样可以用printf输出。void uart1_init(void) { UART1_Init(9600, UART1_WORDLENGTH_8D, UART1_STOPBITS_1, UART1_PARITY_NO, UART1_SYNCMODE_CLOCK_DISABLE, UART1_MODE_TX_RX); } int putchar(int ch) { while (!(UART1_SR UART1_SR_TXE)); UART1_DR (uint8_t)ch; return ch; } void main(void) { CLK_HSIPrescalerConfig(CLK_PRESCALER_HSIDIV1); uart1_init(); gpio_init(); timer1_init(); enableInterrupts(); while (1) { if (frame_ready) { parse_frame(); if (card_ok) { GPIO_WriteHigh(GPIOD, GPIO_PIN_4); printf(Vendor: %03X Card: %08X\r\n, card_version, card_id); card_ok 0; } } } }在STM8S103上使用printf会占不少Flash如果觉得空间紧也可以把格式化拆成十六进制移位发送。不过8KB Flash对于这个工程还是够的只要不引入浮点格式化就没问题。5. 调试过程、实测记录和常见问题5.1 按波形一级一级查比盲调代码高效得多调试这套读卡器示波器是必需品。第一次上电先看NE555输出应该是一个干净的125kHz方波再看天线两端应该出现明显谐振正弦波峰峰电压能到几十伏然后看检波电容正极靠近卡片后能看到包络线被调制成曼彻斯特码形状最后看LM393输出必须是一个边沿很陡的数字方波。每一级波形正常了才轮到单片机解码。我实际调试下来最常见的问题出在天线匹配上。很多人问“代码没问题为什么读不到卡”十有八九是天线没谐振载波根本没发射出去自然也就没有回程调制信号。如果手里只有万用表没有示波器也有个土办法找一台收音机靠近天线如果天线正常谐振收音机里能听到125kHz附近的干扰声。这个办法虽然不精确至少能判断驱动级有没有在工作。5.2 常见问题排查速查表现象可能原因解决方向完全读不到卡125kHz振荡频率偏差大用频率计测NE555输出调RC读卡距离只有几毫米天线没谐振或Q值太低重新调LC电容加大驱动电流串口输出乱码波特率不对或UART初始化有误确认16MHz内部时钟和9600分频偶发误码掉帧比较器参考电压不对用示波器看检波波形微调阈值中断不触发引脚没配置成输入捕获功能检查GPIO复用和TIM1初始化重复输出同一张卡状态机没正常复位检查frame_ready和card_ok清除逻辑这些坑我在一晚上内基本都踩了一遍。尤其“比较器阈值”这个坑最隐蔽阈值调太高低速的曼彻斯特码直接被削得只剩一半阈值调太低底噪又会让比较器乱跳解码状态机频繁重置。所以调试时一定要把示波器探头放在检波输出端慢慢转电位器看到方波占空比稳定了再去动软件。5.3 几个让项目更稳定的经验第一是天线Q值。Q值调高确实能增加读卡距离但过高会有频偏风险手一靠近天线甚至都能改变谐振点。所以我最后会在天线两端并一个几十k的电阻人为把Q值降一点换稳定性。第二是电源隔离。NE555和STM8共用一个5V电源时数字部分的地噪声容易串进模拟检波建议在LM393输出到PB0之间加1k串联电阻同时STM8的电源引脚放一个100nF去耦电容效果立竿见影。第三是软件滤波。边沿间隔判断允许一定容差后如果还是偶发误码可以在串口打印前增加一帧“连续两次读到相同卡号才上报”的判断这个在门禁项目里尤其有用。6. 一点个人体会和后续还能怎么玩整个项目做完我最深的体会是不要把RFID读卡当成一个黑盒尤其是低频125kHz这种“老”技术只要愿意一层一层剥开它真的能变成看得见摸得着的通信过程。用一颗STM8S103就足够应付根本不需要上专用芯片这种“刚好够用”的满足感比直接接一块RC522强太多。后面想继续折腾的话还有几个方向很有意思一是把天线驱动从NE555换成更稳定的晶体振荡器读卡距离还能再提升二是把串口输出改成RS485就可以做成一个简单的门禁读头接上位机管理多张卡三是把读到的卡号存进片内EEPROM做成“白名单”模式不依赖上位机就能控制继电器开门。这套方案的可玩性很高硬件再化简一点甚至能塞进一个小型ABS外壳里做成一个随身读卡工具。如果你也想试试建议从555振荡电路开始一级一级调波形最后再看代码——把物理链路搞明白了程序只是顺理成章的事。