先聊点实际的很多玩STM32的朋友第一次接触RFID时都会遇到同一个尴尬场景板子、RC522模块、OLED全齐了照着网上例程抄了一遍结果屏幕亮是亮了但卡片放上去一点反应都没有要么读出来的UID全是0xFF要么干脆卡死在寻卡循环里。这个STM32F103C8T6RC522射频读卡器OLED完整教程项目就是把这个问题彻底解决的。项目核心是两件事一是用软件SPI模拟驱动RC522读卡把13.56MHz射频卡的UID稳定读出来二是把读到的UID通过OLED屏幕实时显示。整套东西适合正在学STM32的在校生、做课程设计的同学以及想入门RFID开发的电子爱好者门槛不高但涉及的底层知识很密集做完之后对SPI时序、寄存器操作、外设驱动都会有实打实的理解。我这次选STM32F103C8T6最小系统板不是因为这颗芯片多高端而是它性价比极高、资料遍地都是、引脚也够用哪怕是手里只有一块核心板和几根杜邦线也能把项目跑起来。RC522是NXP的经典RFID读卡芯片工作在13.56MHz频段支持ISO14443A协议市面上所有的M1卡S50、S70都能读食堂卡、门禁卡只要协议是14443A基本都通用。OLED用的是最常见的0.96寸SSD1306驱动模块128x64分辨率属于那种人人都用过但又容易踩坑的屏幕。这篇文章不是简单贴一段能跑的代码就完事我会把为什么用软件SPI、RC522的通信流程是什么、OLED怎么初始化、遇到问题怎么排查全部拆开讲清楚。看完之后你不光能把这个项目复现出来换一块屏幕、换一个读卡芯片或者改成硬件SPI你也能独立搞定。1. 项目方案与整体设计思路1.1 为什么坚持用软件SPI而不是硬件SPI这是这个项目第一个要解释清楚的问题。RC522本身支持SPI、I2C、UART三种接口模块出厂默认是SPI模式所以大部分例程都走SPI。但这里有个细节很多人一上来就直接用STM32的SPI1外设配置好复用引脚、设置好时钟极性和相位觉得这样官方又省事。实际调试起来硬件SPI的坑反而比软件SPI更多。常见的就是引脚复用冲突、SPI Mode选错、DMA配置出错而且一旦出错你根本不知道是RC522的问题还是SPI配置的问题。软件SPI说白了就是拿GPIO口用代码模拟SPI时序SCK、MOSI、MISO三根线全部手动拉高拉低。这样做有几个实打实的好处第一引脚任意选避开跟调试口、其他外设冲突F103C8T6引脚本来就少灵活分配很重要第二时序完全可控RC522对SPI的时钟极性有要求软件模拟的时候你可以精确控制每个时钟沿出问题容易定位第三代码逻辑直观一旦你理解了软件SPI怎么写硬件SPI配置反而是水到渠成的事。缺点当然也有就是速度慢但RC522本身的数据交互量不大读个UID也就是几十个字节的事软件SPI的速度完全够用实测下来稳定性也没问题。1.2 硬件连接方案与引脚分配既然决定用软件SPIRC522和OLED的引脚就可以分开规划互不占用。我第一版把两者强行共用一组SCK和MOSI虽然理论上片选不同可以做到总线复用但代码里要频繁切换片选容易出错后面果断拆成两组独立引脚。整体分配如下外设信号STM32引脚RC522SDA(片选)PA4RC522SCKPA5RC522MOSIPA7RC522MISOPA6RC522RSTPA3RC522IRQ不接RC5223.3V/GND3.3V/GNDOLED四线SPICSPB12OLED四线SPIDCPB10OLED四线SPIRSTPB11OLED四线SPISCK(SCL)PB13OLED四线SPISDA(数据)PB15OLED3.3V/GND3.3V/GND这里有两个特别提醒。第一RC522模块上的SDA引脚是SPI片选不是I2C的数据线很多人被这个名字坑过拉低SDA就是选中RC522这个逻辑一定要理顺。第二F103C8T6的PB3、PB4、PA13、PA14、PA15默认是JTAG调试引脚如果你把片选或者时钟线分配在PB4这种引脚上初始化之后不上电焊个电阻把它当普通IO用代码里是一定要调用GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable)的新手很容易在这里卡半天。我用PA4到PA7和PB10到PB15完美避开这些坑你可以直接抄作业。2. 核心原理拆解RC522读卡与UID获取机制2.1 RC522的SPI通信协议是怎么工作的RC522在SPI模式下从机的角色MCU就是主机整个通信过程就是主机发指令、RC522回数据。但RC522的SPI帧格式跟普通SPI从机有一点本质区别每次传输的第一个字节不是数据而是控制字节。控制字节的第0位表示方向0表示写寄存器1表示读寄存器剩余7位是寄存器地址。比如说你要写地址为0x01的寄存器实际发送的字节就是(0x01 1) 0x7E也就是0x02读同一个寄存器就要发送0x02 | 0x80也就是0x82。这个换算逻辑在RC522的标准库里已经封装好了但你得知道它为什么这么算。实际操作的时候写寄存器就是拉低片选、发控制字节、再发数据字节、拉高片选读寄存器是拉低片选、发控制字节、然后主机持续发0x00或者0xFF来产生时钟同时从MISO脚读回从机的数据。关键点是RC522的SPI时序是SPI Mode0也就是空闲时钟为低电平、数据在上升沿采样、下降沿切换。所有软件模拟SPI的代码都必须严格遵循这个模式如果SCK空闲时为高或者采样的沿搞反了读出来的寄存器值就是乱的。2.2 寻卡、防碰撞与UID的完整获取流程RC522读一张M1卡的UID表面上就是一行命令的事底层其实拆成几个步骤。第一步是寻卡MCU往RC522的FIFO里写入寻卡命令0x26然后启动Transceive命令RC522会产生射频场并等待卡片响应。M1卡感应到射频场后会返回ATQA也就是4位卡类型码。第二步是防碰撞发送0x93和0x20两个字节RC522进入防碰撞循环最终从FIFO里读回5个字节前4个字节就是卡片的UID第5个字节是BCC校验码等于前4个字节的按位异或。如果校验不对说明读到的UID有误需要丢弃重来。第三步是选卡把UID回传给卡片完成选择后续才能进行读写扇区操作。如果只是显示UID做到第二步就够了但很多标准库里的PcdRequest函数返回的卡片类型码也需要解析我自己改代码的时候就顺手把卡片类型也显示出来了。这里有个容易忽略的细节发送0x93和0x20到FIFO之后RC522返回的数据可能是4字节也可能是5字节取决于有没有多张卡同时进入射频场。防碰撞命令本身就是为了解决多卡冲突设计的如果你手边有两张M1卡同时放在天线感应区RC522会反复执行防碰撞算法最终一次只能锁定一张卡。想实现多卡识别循环调用防碰撞并处理SEL码就可以了热度词里有人问的RC522多卡识别问题本质就出在这里。2.3 OLED屏幕SSD1306驱动时要注意的状态机OLED模块用的是SSD1306控制器虽然很多卖家说它支持SPI和I2C两种模式但走SPI的时候有个底层差异必须清楚SSD1306的SPI是只写的没有MISO这根线所以你用软件SPI模拟它的时候只需要管SCK和SDA两根线外加DC、CS、RST三根控制线。DC线高低电平决定本次传输的是命令还是显示数据这个状态在初始化过程中需要反复切换写错了屏幕就是花的。SSD1306的显存是1024字节对应128x64像素屏内部把这1024字节分成8页每页128字节每字节的8个bit代表一列的8个像素点。所以你要在某个位置显示一个字符不是直接把ASCII码丢给它而是要先把字符转换成对应的点阵数据。这也是很多人第一次用OLED觉得别扭的地方显示一张图、一个汉字、一个字母本质都是往显存里填点阵看似麻烦其实理解了页列这个寻址模式就豁然开朗。3. 手把手实现软件SPI驱动与读卡显示完整代码3.1 软件SPI底层时序实现底层函数是整套代码的地基。这里我直接用标准外设库GPIO配置成推挽输出50MHzMISO配置成浮空输入。SCK从低电平开始这是Mode0的前提条件。写一个字节就是把数据从高位到低位逐个挪到MOSI线上每挪一位拉一次时钟读一个字节则是在SCK拉低的时候从MISO线上采样然后拉高时钟完成一个周期。这段代码看起来简单但注意我写的顺序先拉低SCK再设置MOSI再拉高SCK这个顺序保证了数据在上升沿到来之前已经稳定是软件SPI不踩时序坑的关键。void Soft_SPI_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Pin GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); } void RC522_CS_LOW(void) { GPIO_ResetBits(GPIOA, GPIO_Pin_4); } void RC522_CS_HIGH(void) { GPIO_SetBits(GPIOA, GPIO_Pin_4); } void RC522_SCK_LOW(void) { GPIO_ResetBits(GPIOA, GPIO_Pin_5); } void RC522_SCK_HIGH(void) { GPIO_SetBits(GPIOA, GPIO_Pin_5); } void RC522_MOSI_LOW(void) { GPIO_ResetBits(GPIOA, GPIO_Pin_7); } void RC522_MOSI_HIGH(void) { GPIO_SetBits(GPIOA, GPIO_Pin_7); } uint8_t RC522_MISO_READ(void) { return GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6); } void RC522_SPI_WriteByte(uint8_t byte) { for (int i 7; i 0; i--) { RC522_SCK_LOW(); if (byte (1 i)) RC522_MOSI_HIGH(); else RC522_MOSI_LOW(); RC522_SCK_HIGH(); } } uint8_t RC522_SPI_ReadByte(void) { uint8_t value 0; RC522_SCK_LOW(); for (int i 7; i 0; i--) { RC522_SCK_LOW(); value 1; if (RC522_MISO_READ()) value | 0x01; RC522_SCK_HIGH(); } RC522_SCK_LOW(); return value; }3.2 RC522寄存器读写与初始化有了底层SPI字节收发函数RC522的寄存器读写就顺理成章了。按照之前说的控制字节规则写寄存器时地址左移一位最低位补0读寄存器时最低位补1。这个封装一定要做得干净后面所有RC522操作都依赖这两个函数。初始化函数里有一长串寄存器配置包括关闭天线、设置定时器、配置中断使能、设置波特率、开启天线等。网上流传的标准初始化序列基本都能跑但要注意复位方式F103的PB2或者PA3接了RC522的RST引脚代码里要先拉低RST复位一次等待10毫秒再拉高让RC522进入正常状态。如果跳过这个复位步骤芯片可能处于异常状态读卡大概率会失败。void RC522_WriteRegister(uint8_t address, uint8_t value) { RC522_CS_LOW(); RC522_SPI_WriteByte((address 1) 0x7E); RC522_SPI_WriteByte(value); RC522_CS_HIGH(); } uint8_t RC522_ReadRegister(uint8_t address) { uint8_t value; RC522_CS_LOW(); RC522_SPI_WriteByte(((address 1) 0x7E) | 0x80); value RC522_SPI_ReadByte(); RC522_CS_HIGH(); return value; } void RC522_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_3; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_3); GPIO_ResetBits(GPIOA, GPIO_Pin_3); delay_ms(10); GPIO_SetBits(GPIOA, GPIO_Pin_3); delay_ms(10); RC522_WriteRegister(CommandReg, PCD_RESETPHASE); delay_ms(10); RC522_WriteRegister(TModeReg, 0x8D); RC522_WriteRegister(TPrescalerReg, 0x3E); RC522_WriteRegister(TReloadReg_1, 0x1E); RC522_WriteRegister(TReloadReg_2, 0x00); RC522_WriteRegister(TxASKReg, 0x40); RC522_WriteRegister(ModeReg, 0x3D); RC522_WriteRegister(RFCfgReg, 0x7F); RC522_WriteRegister(TxSelReg, 0x10); RC522_WriteRegister(RxSelReg, 0x86); RC522_WriteRegister(RxThresholdReg, 0x55); RC522_WriteRegister( demodulator, 0x48); RC522_WriteRegister(ControlReg, 0x0C); RC522_WriteRegister(CRCResultReg_1, 0x02); RC522_WriteRegister(CRCResultReg_2, 0x00); RC522_WriteRegister(GSPReg, 0x18); RC522_WriteRegister(ModuleMinReg, 0x86); RC522_WriteRegister(TestReg1Reg, 0x31); RC522_WriteRegister(TestReg2Reg, 0x09); RC522_WriteRegister(TestPinEnReg, 0x00); RC522_WriteRegister(VersionReg, 0x91); RC522_WriteRegister(ComIEnReg, 0x00); RC522_WriteRegister(DivIEnReg, 0x00); RC522_WriteRegister(ComIrqReg, 0x7F); RC522_WriteRegister(DivIrqReg, 0x00); RC522_WriteRegister(Status2Reg, 0x00); RC522_WriteRegister(FIFOLevelReg, 0x80); RC522_WriteRegister(WaterLevelReg, 0x14); RC522_WriteRegister(ControlReg, 0x00); RC522_WriteRegister(BitFramingReg, 0x00); RC522_WriteRegister(CollReg, 0x80); SetBitMask(ControlReg, 0x04); }这段代码里的delay_ms函数是软件延时直接用SysTick实现就行具体延时时间不用太精确但RC522上电复位那10ms不能省。寄存器配置序列中间有两个地方网上不同版本有差异一个是RST引脚拉高的时间另一个是最后开启天线的时机。我自己踩过的坑是天线开得太早导致RC522射频场还没稳定就开始读卡容易出现寻卡失败所以初始化最后加了一个延时再开启天线。3.3 寻卡与防碰撞核心函数实现寻卡和防碰撞是整个读卡流程的核心代码逻辑一定要完全理解再改。PcdRequest函数首先清空Status2Reg里的缓冲状态设置BitFramingReg为0x07表示发送7位数据然后把寻卡命令字节写入FIFO最后通过PcdComMF522发送Transceive命令。PcdComMF522是RC522最复杂的函数它的本质是串起一个完整的事务清中断、关命令、清FIFO、写入要发送的数据、启动命令、轮询中断标志、判断错误寄存器、读出接收到的数据长度和数据内容。防碰撞函数PcdAnticoll和寻卡类似区别在于往FIFO写入了0x93和0x20两个字节。0x93是防碰撞命令的SEL码表示针对完整4字节UID的防碰撞操作0x20表示初始化的NVB也就是有效位数为2字节。执行完成之后从FIFO读回的数据长度应该是5字节其中前4字节是UID最后1字节是BCC校验。这个校验我单独写了一个函数判断如果异或结果不对直接返回错误避免把脏数据送到OLED上显示。char PcdRequest(uint8_t req_code, uint8_t *pTagType) { uint8_t status MI_ERR; uint8_t buffer[2]; uint8_t len 0; RC522_WriteRegister(Status2Reg, 0x08); RC522_WriteRegister(BitFramingReg, 0x07); RC522_WriteRegister(TxControlReg, 0x03); buffer[0] req_code; status PcdComMF522(PCD_TRANSCEIVE, buffer, 1, buffer, len); if (status MI_OK) { pTagType[0] buffer[0]; pTagType[1] buffer[1]; } return status; } char PcdAnticoll(uint8_t *pSnr) { uint8_t status MI_ERR; uint8_t buffer[5]; uint8_t len 0; RC522_WriteRegister(Status2Reg, 0x08); RC522_WriteRegister(BitFramingReg, 0x00); RC522_WriteRegister(CollReg, 0x80); buffer[0] PICC_ANTICOLL; buffer[1] 0x20; status PcdComMF522(PCD_TRANSCEIVE, buffer, 2, buffer, len); if (status MI_OK) { if (buffer[0] (buffer[1] ^ buffer[2] ^ buffer[3] ^ buffer[4])) { for (int i 0; i 4; i) pSnr[i] buffer[i]; } else { status MI_ERR; } } return status; }3.4 OLED初始化与显示UID的完整实现OLED软件SPI的底层跟RC522大同小异只是少了MISO读取写一个字节的函数可以直接复用。SSD1306的初始化序列是一串命令很多新手喜欢直接从网上复制一旦屏幕没显示就一脸懵。我建议把初始化序列拆开理解0xAE是关显示、0xD5和0x80是设置时钟分频、0xA8和0x3F是设置驱动路数、0x8D和0x14是打开内部电荷泵、0x20和0x02是设置页寻址模式、0xA1和0xC8是设置屏幕扫描方向这个决定坐标原点在哪、0xAF是开显示。如果你的屏幕显示内容镜像了或者倒置了就改0xA1和0xC8这两个参数这是OLED调试最常用的两个开关。显示字符的核心是把字符点阵写入显存。我这里准备了一个简单的ASCII 8x6字库每个字符用6个字节表示每字节的高位对应像素点。显示一个字符的流程是设定页地址和列地址然后连续写入6字节点阵数据。显示UID: xx xx xx xx这样的字符串时把ASCII码减去0x20作为字库索引逐个字符显示即可。汉字点阵数据需要额外取模我这边为了演示只实现了ASCII但思路是一致的。void OLED_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Pin GPIO_Pin_10 | GPIO_Pin_11 | GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_15; GPIO_Init(GPIOB, GPIO_InitStructure); } void OLED_WriteCmd(uint8_t cmd) { OLED_DC_LOW(); OLED_CS_LOW(); OLED_SPI_WriteByte(cmd); OLED_CS_HIGH(); } void OLED_WriteData(uint8_t data) { OLED_DC_HIGH(); OLED_CS_LOW(); OLED_SPI_WriteByte(data); OLED_CS_HIGH(); } void OLED_Init(void) { delay_ms(100); OLED_RST_LOW(); delay_ms(100); OLED_RST_HIGH(); OLED_WriteCmd(0xAE); OLED_WriteCmd(0xD5); OLED_WriteCmd(0x80); OLED_WriteCmd(0xA8); OLED_WriteCmd(0x3F); OLED_WriteCmd(0xD3); OLED_WriteCmd(0x00); OLED_WriteCmd(0x40); OLED_WriteCmd(0x8D); OLED_WriteCmd(0x14); OLED_WriteCmd(0x20); OLED_WriteCmd(0x02); OLED_WriteCmd(0xA1); OLED_WriteCmd(0xC8); OLED_WriteCmd(0xDA); OLED_WriteCmd(0x12); OLED_WriteCmd(0x81); OLED_WriteCmd(0xCF); OLED_WriteCmd(0xD9); OLED_WriteCmd(0xF1); OLED_WriteCmd(0xDB); OLED_WriteCmd(0x40); OLED_WriteCmd(0xA4); OLED_WriteCmd(0xA6); OLED_WriteCmd(0xAF); OLED_Clear(); }显示UID的时候我建议直接用sprintf把4字节的十六进制数格式化到一个字符数组里再整体显示出来。这里有个小坑sprintf在标准外设库工程里使用需要包含stdio.h并且勾选Use MicroLIB否则浮点格式化会占用大量flash虽然我们只格式化整数但保险起见还是把MicroLIB打开。实测显示效果大概是第一行显示RFID Reader第二行显示UID:第三行显示大号的十六进制字符串这样界面清晰直观。3.5 主函数完整流程与读卡循环主函数的逻辑核心是死循环读卡加上超时判断。先初始化系统时钟、延时函数、RC522软SPI、OLED然后OLED显示开机界面进入循环。每次循环先调用PcdRequest寻卡如果寻卡成功再调用PcdAnticoll取UID把UID格式化显示到OLED上如果寻卡失败屏幕上显示NO CARD并在下一次循环时清掉上一次的UID显示。这里有一个细节不能让程序在PcdRequest里面死等因为PcdComMF522内部有轮询超时计数器超时会返回错误所以不会卡死但要注意把RC522的命令状态复位干净否则第二次寻卡会失败。int main(void) { uint8_t uid[4] {0}; uint8_t status MI_ERR; char displayBuf[20] {0}; Delay_Init(); Soft_SPI_GPIO_Init(); RC522_Init(); OLED_GPIO_Init(); OLED_Init(); OLED_ShowString(0, 0, RFID Reader v1.0); delay_ms(1000); while (1) { status PcdRequest(PICC_REQIDL, cardType); if (status MI_OK) { status PcdAnticoll(uid); if (status MI_OK) { sprintf(displayBuf, UID: %02X %02X %02X %02X, uid[0], uid[1], uid[2], uid[3]); OLED_Clear(); OLED_ShowString(0, 0, Card Detected); OLED_ShowString(0, 2, displayBuf); OLED_Refresh(); } } else { OLED_Clear(); OLED_ShowString(0, 0, NO CARD); OLED_Refresh(); } delay_ms(300); } }4. 实测中的坑常见问题与排查技巧实录4.1 读卡失败、UID全FF的排查路线读卡失败是这个问题最高频的故障现象也五花八门。如果OLED一直显示NO CARD先检查硬件供电。RC522模块虽然标称3.3V供电但它的天线驱动电路对电压波动很敏感用USB转TTL上的3.3V供电一旦天线发射电流拉低电压就会导致寻卡失败。我实测下来最稳定的是直接从STM32最小系统板的3.3V引脚取电如果板子的3.3V稳压芯片输出能力足够完全没有问题。杜邦线也是重点RC522天线部分对寄生电容很敏感天线到模块的线尽量短模块到STM32的SPI线不要超过20cm否则高速翻转的时钟信号会有严重的过冲和振铃。如果显示Card Detected但UID是FF FF FF FF那问题几乎一定出在SPI时序上。很多人抄代码时用的是硬件SPI的例子硬件SPI的Mode0跟软件SPI的时序略有差别尤其是在采样沿的把握上。软件SPI读寄存器的时候我代码里是SCK拉低之后读MISO然后在SCK拉高时完成一个时钟周期这个顺序对应的是Mode0的数据在上升沿采样。如果你把读MISO的时机放在SCK拉高之后读到的就是下一个bit的起始值整个字节就会错位读出来自然全是1。记住MISO数据的采样必须发生在SCK低电平期间这是一个很容易踩却又坑哭很多人的细节。4.2 OLED屏幕显示异常的快速判断OLED最多的问题是白屏和乱码。白屏的第一嫌疑是复位引脚拉低RST后才初始化如果RST一直处于低电平SSD1306就一直处于复位状态命令和数据都进不去。第二嫌疑是内部电荷泵没有使能初始化序列里0x8D后面必须跟0x14如果漏了这一条屏幕会一直保持睡眠状态。第三嫌疑是CS片选逻辑反了有的模块CS是低有效有的模块是高有效这个看模块背面丝印或者卖家原理图确认一下。乱码或者显示内容错位多半是DC线状态不对。写命令时DC必须为低写数据时DC必须为高如果在初始化序列结束时DC停留在高位后面每次发命令都会被当成数据写入显存屏幕就会出现马赛克一样的随机点阵。这个问题我在调试时遇到过后来在每次写命令和写数据函数的开头都强制设置DC的电平问题就消失了。还有一个容易忽略的点SSD1306的页地址模式在写完一页之后列地址会自动回卷到这一页的开头如果你连续写多页内容必须每次重新设置页地址和列地址否则后面的内容会覆盖前面的内容。4.3 软件SPI稳定性的三条硬化经验第一GPIO速度一定要配置成50MHz。软SPI的时序瓶颈在于GPIO翻转速度如果配置成2MHz一个字节里面8个时钟周期会被拉得非常长RC522等待响应的时间就容易被拉长整体读卡速度感人。配置成50MHz后软件模拟的SCK频率能到几百kHz到1MHz读卡流畅度完全可用。第二合理使用延时函数但不要过度。很多软件SPI例程在每个时钟沿之后都加delay确保时序稳定这在小系统板上是稳妥的但延时可别太长。当时我为了排查一个奇怪问题把延时从1微秒加到10微秒结果读卡响应从几十毫秒变成几百毫秒而且偶尔还会出现读卡超时。正确做法是先不加延时跑一遍如果稳定就保持不稳定再逐级加找到一个刚好的平衡点。第三RC522的IRQ引脚虽然在这个项目中没有使用但如果你后续要改成中断方式读卡IRQ引脚上拉或者浮空要看模块丝印有的模块IRQ已经是开漏输出需要外部上拉。我用轮询方式就不必纠结这个项目里没有接IRQ。想让读卡更灵敏可以调整RFCfgReg里的接收器增益默认0x7F是最高增益如果读卡距离太近或者不稳定尝试调低一级到0x5F或0x3F有时候反而更稳定。4.4 从读UID继续扩展的四个方向做完这个项目掌握的能力完全可以移植到很多方向。第一个方向是门禁系统在PcdAnticoll之后继续调用PcdSelect选中卡片再对指定扇区做密钥认证和读写操作就能实现白名单开锁。第二个方向是考勤打卡板子加一颗DS1302或者用STM32内部的RTC记录时间每次读到的UID连同时间戳一起存到外部Flash里。第三个方向是多卡识别循环执行防碰撞流程配合卡片序列号区分当前是第几张卡可以做多人同时刷卡的场景。第四个方向是纯软件升级把RC522和OLED都改成硬件SPI同一个工程会快很多代码逻辑保持兼容可以作为学习硬件SPI的过渡项目根子上还是这套时序理解。另外说一个我在实际测试中发现的有用的点OLED显示UID的时候建议隔几百毫秒刷新一次并做防抖处理不然卡片在感应区轻微晃动会导致UID频繁重新读取显示内容会闪烁。做法很简单记录上次读到的UID如果跟当前一样就不刷新屏幕如果不一样或者变成空卡才更新这样界面干净很多。这个项目整体做下来最大的收获其实不是几个外设能跑通而是彻底搞懂了SPI通信在不同器件上的细微差别RC522的控制字节格式、SSD1306的DC状态切换、软件模拟与硬件外设的关系这些东西换一个芯片、换一块屏幕仍然成立迁移成本非常低。