简介:一套用于快速搭建NFC读写系统的完整工程源码,基于STM32F407与捷联微芯SL2823 NFC模块,适合嵌入式开发者、物联网学习者及毕业设计人员参考。工程以Keil项目形式交付,代码覆盖SL2823的初始化配置、I2C/SPI接口通信、寄存器读写、命令交互、中断处理、CRC校验与超时等错误恢复,以及NFC应用层数据解析等关键环节,并给出可直接编译运行的底层驱动骨架,便于二次开发。压缩包共142个文件,以68个.h头文件和58个.c源文件为主,搭配启动文件、链接脚本及工程配置文件,整体大小约752KB,结构清晰,能帮助阅读者快速定位驱动代码。目前已有240人学习使用,对于需要在STM32平台接入NFC功能、理解SL2823驱动流程或进行项目预研的开发者,这份代码可以提供较完整的参考实现,涵盖从硬件初始化到应用交互的完整链路。
1. 在 STM32F407 上把 SL2823 当主力 NFC 读卡器,而不是继续用 RC522
一个门禁读头或产线工位要读 ISO14443A 的 M1 卡,主控是 STM32F407,NFC 前端选什么?老方案是 RC522,便宜但底层状态机烦人,天线参数稍微偏一点就寻卡不稳;换 PN532 又要多一整套固件和串口协议。捷联微芯 SL2823 正好卡在这个中间位:同样是 13.56MHz 读卡前端,支持 ISO14443A/B 和 Mifare 系列,成本可控,SPI/UART/I2C 可选,很多板厂直接把它做成邮票孔模组,烧录好固件后对 MCU 就是一个"发命令、收状态"的外设。这篇文章按我最常走的路径讲:从接口选型、CubeMX 初始化、驱动分层,到寻卡、M1 认证读写和天线调试,全程用硬件 SPI 而不是模拟时序,代码基于 HAL 库,标准库用户也能对照改。适合正在做门禁、充电桩、固定资产盘点或者实验室工具的人。
2. 先定接口和硬件连接:SPI 为什么比模拟 I2C 省心
2.1 SL2823 的三种接口怎么选
SL2823 这类读卡芯片一般同时引出 SPI、UART、I2C。看到网上很多人搜"stm32f407 模拟 i2c"甚至"stm32f407 硬件 iic",多半是被 I2C 地址和 ACK 时序折腾过。我一般直接选 SPI,原因有三个:
- F407 的 SPI1 挂在 APB2 上,分频后能稳定跑到 10MHz 以上,而读卡芯片的 SPI 从机上限通常就是 10MHz,刚好吃满。
- SPI 是主从片选制,不存在 I2C 那种总线竞争和地址配置问题,驱动代码里少一个 7 位地址的处理。
- SL2823 的天线收发寄存器动辄几十字节,SPI 全双工一次搬完,配合 DMA 几乎不占 CPU。
如果你一定要用 UART,那走的是 AT 指令固件通道,不是寄存器通道,本文不展开。I2C 模式留给引脚紧张的板子,但注意上拉电阻要按芯片手册配,我遇到过 4.7k 上拉太强导致 SCL 高电平被拉低、NFC 命令频繁超时的情况。
2.2 CubeMX 里 SPI 与 GPIO 的最小配置
以 SPI1 为例,F407 的 PA5、PA6、PA7 分别是 SCK、MISO、MOSI,片选和复位用普通 GPIO 更稳。我习惯把 NSS 设为软件管理,避免硬件 NSS 在通信中途被自动拉低造成帧错位。
| SL2823 引脚 | STM32F407 引脚 | 模式 |
|---|---|---|
| VCC | 3V3 | 电源,注意天线驱动电流会瞬态拉高 |
| GND | GND | 共地,靠近芯片引脚 |
| SCK | PA5 / SPI1_SCK | 复用推挽 |
| MOSI (DIO) | PA7 / SPI1_MOSI | 复用推挽 |
| MISO | PA6 / SPI1_MISO | 复用输入 |
| CS | PA4 | GPIO 推挽输出,平时拉高 |
| RST | PE2 | GPIO 推挽输出,上电先拉低 |
| IRQ | PE4 | GPIO 输入,可配 EXTI 下降沿中断 |
CubeMX 里 SPI1 的参数这样填:Full-Duplex Master,8 Bits,Motorola MSB First,Prescaler 选 8,即 84MHz / 8 约 10.5MHz,超过 SL2823 标称上限的话降到 16 分频。CPOL/CPHA 取决于芯片手册,很多读卡芯片要求 CPOL=0、CPHA=0,也就是空闲时钟低电平、第一个边沿采样;如果你照抄别人的代码发现读回全是 0xFF,先查这一项。
2.3 上电时序和复位代码
读卡芯片的复位不是简单的拉高拉低。SL2823 内部有晶振起振和固件装载过程,掉电再上电时,必须等 VCC 稳定后再释放复位。我见过有人把 RST 和 MCU 的复位引脚接在一起,导致 MCU 和 NFC 同时起来,NFC 固件还没就绪,第一条命令就超时。
void sl2823_hard_reset(void) { HAL_GPIO_WritePin(GPIOE, RST_Pin, GPIO_PIN_RESET); // 拉低复位 HAL_Delay(10); // 保证内部放电完成 HAL_GPIO_WritePin(GPIOE, RST_Pin, GPIO_PIN_SET); // 释放复位 HAL_Delay(50); // 等固件启动,至少 30ms }这段代码里的 10ms 不是随便写的:F407 的 GPIO 翻转很快,但芯片内部电源去耦电容需要时间放电。50ms 的等待时长我一般会在调试期放大到 200ms 验证一次,确认系统能跑后再缩回来。如果复位后立刻发命令,返回值往往是 0x01 或 0xFF,这是新手最容易踩的坑。
3. 驱动分三层:寄存器读写、命令帧、应用原语
3.1 底层 SPI 收发函数
驱动不要所有功能堆在一个大函数里。我的做法是先写一个sl2823_io(),它负责单字节全双工交换,所有读写都基于它。片选在每次交换前后拉低拉高,保证芯片不会把相邻两次访问当成同一帧。
static uint8_t sl2823_io(uint8_t out) { uint8_t in = 0; HAL_GPIO_WritePin(GPIOA, CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(&hspi1, &out, &in, 1, 10); HAL_GPIO_WritePin(GPIOA, CS_Pin, GPIO_PIN_SET); return in; }逻辑说明:HAL 的TransmitReceive是全双工,一个时钟周期内同时发出out并采样收进in,NFC 芯片的命令返回就是这样以交换方式完成的。超时时间 10ms 已经很大,因为单字节 SPI 在 10MHz 下不到 1us,真正耗时的是芯片内部把射频命令送到卡片并等卡片应答,但那部分由芯片处理,不会堵在 SPI 总线里。
写寄存器和读寄存器基于这个原语:
void sl2823_write_reg(uint8_t reg, uint8_t val) { CS_LOW(); sl2823_io(0x00); // 方向位:写 sl2823_io(reg & 0x7F); // 寄存器地址,清掉最高位 sl2823_io(val); // 数据 CS_HIGH(); } uint8_t sl2823_read_reg(uint8_t reg) { uint8_t rx = 0; CS_LOW(); sl2823_io(0x01); // 方向位:读 sl2823_io(reg & 0x7F); rx = sl2823_io(0x00); // 空字节,时钟移出数据 CS_HIGH(); return rx; }参数说明:reg & 0x7F把地址限制在 7 位,最高位留给方向标记,这是多数 SPI 读卡芯片的通用做法。如果你手上的 SL2823 手册用的是"先发地址,再用一条独立命令区分读写",那只需把 0x00/0x01 替换成对应命令字即可。
3.2 命令帧格式与校验
SL2823 固件层通常要求主机把"命令字 + 长度 + 数据 + CRC"打包成帧,芯片处理完帧后回一个带状态字的响应帧。帧格式各家略有不同,但思路一致:
| 域 | 长度 | 说明 |
|---|---|---|
| 命令字 | 1 字节 | 如软复位 0x01、发送射频命令 0x10 |
| 长度 | 1 字节 | 后续数据字节数,不含 CRC |
| 数据 | N 字节 | 具体命令参数 |
| CRC | 2 字节 | 通常从命令字算到数据末尾 |
因为帧长度很短,CRC 用查表法最省事,F407 主频高,逐字节计算也毫秒级完成。实际发送射频命令时,我推荐直接封装一个 transceive 原语,内部完成组帧、校验、发送、收状态:
uint8_t sl2823_transceive(uint8_t *tx, uint16_t tx_len, uint8_t *rx, uint16_t *rx_len) { uint8_t frame[64]; uint16_t crc; frame[0] = CMD_TRANSCEIVE; // 发送并接收 frame[1] = (uint8_t)tx_len + 2; // 数据长度 frame[2] = (uint8_t)tx_len; // 子长度 memcpy(&frame[3], tx, tx_len); crc = sl2823_crc16(frame, 3 + tx_len); frame[3 + tx_len] = (uint8_t)(crc >> 8); frame[3 + tx_len + 1] = (uint8_t)(crc & 0xFF); // 这里调用片选和全帧 SPI 发送,省略重复代码 sl2823_frame_send(frame, 5 + tx_len); // 读取响应帧,检查状态字 if (sl2823_frame_recv(rx, rx_len) != STATUS_OK) { return ERR_RF_TIMEOUT; } return STATUS_OK; }逻辑说明:frame[2]是子长度,这是因为部分固件把发送字节数和有效数据区分开,方便芯片内部的 FIFO 管理。你移植到别的读卡芯片时,这层可以按手册裁掉。返回值ERR_RF_TIMEOUT代表芯片已经把射频命令发出去了,但没等到卡片应答,这种超时和 SPI 通信错误要分开处理,上层才能区分"没卡"和"坏了"。
3.3 初始化序列:软复位到天线开启
上电后光拉高复位引脚还不够,通常要再走一遍软复位和配置读取。配置里最重要的是射频增益和应答超时窗口:增益太高会把相邻卡片一起吵醒,太低则读卡距离缩水到一两厘米。
uint8_t sl2823_init(void) { sl2823_hard_reset(); if (sl2823_write_reg(REG_SOFTRESET, 0x01) != STATUS_OK) { return ERR_RESET; } HAL_Delay(20); uint8_t ver = sl2823_read_reg(REG_VERSION); if (ver == 0x00 || ver == 0xFF) { return ERR_VERSION; // 芯片没进工作状态 } sl2823_write_reg(REG_RF_GAIN, 0x18); // 中等增益,距离约 4cm sl2823_write_reg(REG_RF_TIMEOUT, 0x30); // 应答超时约 10ms sl2823_write_reg(REG_TX_MODE, 0x03); // 开天线,使能收发 return STATUS_OK; }参数说明:REG_VERSION读到 0x00 或 0xFF 是常见的两个失败形态。0x00 说明芯片刚复位完还在忙,需要再等等;0xFF 说明 SPI 时序不对——先查 CPOL/CPHA,再查片选极性。REG_RF_GAIN的 0x18 不是通用值,不同的天线板阻抗不同,这个值要在样机上现场调。
4. 寻卡、防碰撞与 M1 块读写实战
4.1 ISO14443A 的 REQA 与防碰撞
RFID 读卡的第一步是让卡片应答。ISO14443A 规定发送 0x26 唤醒处于空闲状态的卡片,卡片回 2 字节 ATQA。SL2823 的 transceive 原语会直接把这个过程透传给 MCU。
uint8_t nfc_request_a(nfc_card_t *card) { uint8_t cmd = 0x26; // REQA uint8_t rx[8]; uint16_t len = 0; if (sl2823_transceive(&cmd, 1, rx, &len) != STATUS_OK) { return ERR_NO_CARD; } if (len < 2) { return ERR_FRAME; // ATQA 必须是 2 字节 } card->atqa[0] = rx[0]; card->atqa[1] = rx[1]; return STATUS_OK; }卡返回 ATQA 后,如果其长度为 7 字节的 UID,就要走三层防碰撞(级联 1、2、3)。最常用的 M1 S50 是 4 字节 UID,只走级联 1。防碰撞命令是 0x93,第二字节是 NVB,0x20 表示请求完整 UID,0x70 表示带部分 UID 解析冲突位。
uint8_t nfc_anticoll_uid4(nfc_card_t *card) { uint8_t tx[2] = {0x93, 0x20}; uint8_t rx[8]; uint16_t len = 0; if (sl2823_transceive(tx, 2, rx, &len) != STATUS_OK) { return ERR_NO_CARD; } if (len != 5) { // 4 字节 UID + 1 字节 BCC return ERR_FRAME; } for (int i = 0; i < 4; i++) { card->uid[i] = rx[i]; } card->uid_len = 4; // 校验 BCC:前 4 字节异或等于第 5 字节 if ((rx[0] ^ rx[1] ^ rx[2] ^ rx[3]) != rx[4]) { return ERR_BCC; } return STATUS_OK; }逻辑说明:BCC 校验失败最常见的原因是场内有另一张卡同时应答,导致 4 字节 UID 被混合。遇到这种情况,做法是重新发 REQA 再防碰撞,最多重试 3 次。另外注意len != 5的判断必须在读数据之前,否则越界访问会把栈里的脏数据当 UID 用。
4.2 M1 认证:装载密钥与验证
M1 S50 每个扇区有 A 密钥和 B 密钥。SL2823 读取数据块之前必须先对目标块做认证。很多教程只写"调用认证命令",却不说密钥字节序——密钥从高位到低位逐字节放进命令,不能按数组顺序想当然。我一般这样封装:
uint8_t m1_auth_block(nfc_card_t *card, uint8_t block, uint8_t key_type, uint8_t *key6) { uint8_t tx[8]; uint8_t rx[8]; uint16_t len = 0; tx[0] = (key_type == KEY_TYPE_A) ? 0x60 : 0x61; tx[1] = block; memcpy(&tx[2], key6, 6); // 6 字节密钥,高位在前 if (sl2823_transceive(tx, 8, rx, &len) != STATUS_OK) { return ERR_AUTH; // 认证失败或超时 } // 芯片返回 0x00 表示认证通过 return (len > 0 && rx[0] == 0x00) ? STATUS_OK : ERR_AUTH; }参数说明:block是绝对块号,0~63。M1 的前 32 个扇区每扇 4 块,后 8 个扇区每扇 16 块,直接传绝对块号可以让上层少做扇区换算。密钥长度必须是 6 字节,多一个少一个都可能被芯片当作非法帧丢弃,返回 0x01。默认密钥全 0xFF 在生产环境必须换掉,否则等于给卡片开后门。
4.3 数据块读写与值块操作
认证通过后就能读写了。读块命令 0x30,写块 0xA0,数据总是 16 字节。写块前必须保证卡片已经在选卡状态,SL2823 在认证成功后会自动保持选卡态,但如果中间做过寻卡操作,选卡态会丢失。
uint8_t m1_read_block(uint8_t block, uint8_t data[16]) { uint8_t tx[2] = {0x30, block}; uint8_t rx[18]; uint16_t len = 0; if (sl2823_transceive(tx, 2, rx, &len) != STATUS_OK) { return ERR_TIMEOUT; } if (len != 16) { return ERR_FRAME; // 数据区必须是完整 16 字节 } memcpy(data, rx, 16); return STATUS_OK; } uint8_t m1_write_block(uint8_t block, uint8_t data[16]) { uint8_t tx[18]; uint8_t rx[4]; uint16_t len = 0; tx[0] = 0xA0; tx[1] = block; memcpy(&tx[2], data, 16); if (sl2823_transceive(tx, 18, rx, &len) != STATUS_OK) { return ERR_WRITE; // 卡片可能处于只读状态 } return STATUS_OK; }M1 的块 3 是扇区尾块,里面存着密钥 A、存取控制位、密钥 B。写这个块要格外小心:一旦存取控制位写错,这一整个扇区就永久锁死,只能报废。所以我给块 3 单独加一个上层保护函数,在业务代码里不允许直接调m1_write_block去写块 3。
4.4 返回值状态码与排查方向
SL2823 的响应帧里状态字可以用一张表快速定位问题:
| 状态字 | 含义 | 排查方向 |
|---|---|---|
| 0x00 | 正常 | 无需处理 |
| 0x01 | 帧格式错误 | 查命令字、长度字节、CRC |
| 0x02 | 无卡片应答 | 查天线匹配、卡是否在感应区 |
| 0x0A | 认证失败 | 查密钥字节序、块号、是否选卡 |
| 0x0B | 写块失败 | 查是否越权、块尾是否锁定 |
这张表是通用读卡芯片的常见状态语义,你的 SL2823 固件版本如果不同,以数据手册附录的状态码清单为准。排查时我在工程里保留一个调试宏,把每次 transceive 的原始帧打印到串口,状态字异常时能直接看到芯片回的是什么,而不是靠猜。
5. 天线调谐快速验证与 FreeRTOS 任务接入
5.1 没有网分仪怎么确认天线匹配
天线匹配是 SL2823 最玄学的一环。理想情况是用网络分析仪看 13.56MHz 处的 S11,低于 -15dB 才算合格。手头没设备时,我一般用示波器探头(10x 档)轻触天线线圈两端,看谐振波形峰值;再测一个最实用的指标:卡片从贴近到完全离开,连续读卡 100 次,记录最大稳定读卡距离。SL2823 的射频增益寄存器可以从默认值往上调几格,如果距离不升反降,说明天线已经过耦合,反过来降增益往往立竿见影。
5.2 把 NFC 轮询挂进 FreeRTOS
在正点原子 F407 板子上跑 FreeRTOS 时,NFC 任务放 5ms 周期、优先级中低即可,因为 SL2823 的 SPI 收发一次不到 1ms,主要是卡片应答等射频时间。任务主体不要做重消费逻辑,读到 UID 后只投递到队列,由业务任务处理。
void nfc_task(void *arg) { nfc_card_t card; uint8_t key[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; for (;;) { if (nfc_request_a(&card) == STATUS_OK) { if (nfc_anticoll_uid4(&card) == STATUS_OK) { // 认证块 1,然后读 16 字节数据 if (m1_auth_block(&card, 1, KEY_TYPE_A, key) == STATUS_OK) { uint8_t block[16]; if (m1_read_block(1, block) == STATUS_OK) { xQueueSend(nfc_queue, &card, 0); } } } } vTaskDelay(pdMS_TO_TICKS(NFC_POLL_INTERVAL_MS)); } }轮询周期我单独定义成NFC_POLL_INTERVAL_MS宏,默认 80ms。这个值不是拍脑袋:读卡芯片寻卡一次最快 20ms,如果事务里还带认证和读块,50ms 起步。调小到 30ms 能提升刷卡响应,但天线会持续发射射频场,功耗和电磁辐射都会上去。只在门禁这类非低功耗场景才用 30ms,产线和实验室建议保持 100ms 以上,配合卡片只要在感应区停留足够时间就能被读到。
本文还有配套的精品资源,点击获取