最近在开发智能门禁系统时,我发现很多开发者对微信小程序的NFC功能使用存在不少困惑。特别是针对MifareClassic M1卡的操作,网上资料比较零散。今天我就把自己在实际项目中积累的经验完整分享出来,手把手带你搞定M1卡的认证与数据读写。
微信小程序的NFC API其实封装得相当友好,但有几个关键点需要注意:首先,你的手机必须支持NFC功能;其次,系统NFC开关需要打开;最后,小程序必须获得用户授权才能使用NFC。我在第一次开发时就因为没注意这些基础条件,调试了半天才发现是手机NFC没开启。
MifareClassic M1卡是市面上最常见的IC卡之一,广泛用于门禁、公交卡等场景。它的存储结构就像一栋16层的公寓楼,每层有4个房间(块),每个房间能存放16字节数据。特别要注意的是每层的最后一个房间(块3)存放着重要的钥匙和门禁规则 - 这就是我们后面要重点操作的密钥块。
M1卡的存储结构可以用"小区-楼栋-房间"来形象理解。整个小区有16栋楼(扇区),每栋楼有4个房间(块),每个房间能放16个字节的数据。其中:
实际开发中,我们最常操作的是块0-2的用户数据区。比如在会员卡系统中,块0可以存会员ID,块1存积分,块2存有效期。但要注意,每个扇区的块3是特殊的,它决定了这个扇区的安全规则。
块3的6-9字节存放的访问控制位就像小区的门禁规则,决定了:
举个例子,如果控制位设置成Key A只能读不能写,那么即使用户知道Key A,也无法修改数据。我在开发门禁系统时就遇到过,客户提供的测试卡控制位设置得非常严格,导致写入总是失败,后来才发现需要同时提供Key A和Key B才能完成完整操作。
在微信小程序中使用NFC功能,首先要初始化适配器。这里有个坑要注意:必须确保用户已经授权NFC权限,否则会静默失败。我建议在onLoad生命周期里就初始化:
javascript复制let adapter = null;
Page({
onLoad() {
// 检查NFC支持情况
if (!wx.getNFCAdapter) {
this.showToast('当前版本不支持NFC功能');
return;
}
adapter = wx.getNFCAdapter();
this.startDiscovery();
},
startDiscovery() {
adapter.startDiscovery({
success: () => {
console.log('NFC适配器启动成功');
this.registerEvents();
},
fail: (err) => {
let msg = 'NFC启动失败';
if (err.errCode === 13000) msg = '设备不支持NFC';
else if (err.errCode === 13001) msg = '请打开系统NFC开关';
this.showToast(msg);
}
});
}
})
当NFC适配器检测到卡片时,会触发onDiscovered事件。这里需要判断卡片类型,我们只处理MifareClassic类型的卡片:
javascript复制registerEvents() {
adapter.onDiscovered((res) => {
if (res.techs.includes('MIFARE Classic')) {
this.handleMifareCard(res);
} else {
this.showToast('请使用M1卡');
}
});
}
handleMifareCard(res) {
// 获取卡片UID
const uid = res.id;
console.log('检测到M1卡,UID:', uid);
// 后续认证操作...
}
M1卡每个扇区都有两套密钥:Key A和Key B。实际开发中会遇到三种情况:
我建议在代码中同时支持两种密钥,通过参数控制使用哪种:
javascript复制function authenticateSector(sector, keyType, key) {
const mifare = adapter.getMifareClassic();
const cmd = new Int8Array(12);
// 0x60表示Key A,0x61表示Key B
cmd[0] = keyType === 'A' ? 0x60 : 0x61;
cmd[1] = sector * 4; // 计算块地址
// 填充密钥
for (let i = 0; i < 6; i++) {
const byteStr = key.substr(i*2, 2);
cmd[i+6] = parseInt(byteStr, 16);
}
return new Promise((resolve, reject) => {
mifare.transceive({
data: cmd.buffer,
success: () => resolve(),
fail: (err) => reject(err)
});
});
}
在实际项目中,认证失败主要有以下几种情况:
建议在代码中加入详细的错误处理:
javascript复制async function readSector(sector, keyA, keyB) {
try {
// 先尝试Key A
await authenticateSector(sector, 'A', keyA);
} catch (err) {
console.log('Key A认证失败,尝试Key B');
try {
await authenticateSector(sector, 'B', keyB);
} catch (err2) {
throw new Error('双重认证均失败,请检查密钥和控制位');
}
}
// 认证成功后的读取操作...
}
认证通过后,就可以读取块数据了。M1卡的读取是以块为单位的,每次读取一个块(16字节):
javascript复制function readBlock(block) {
const mifare = adapter.getMifareClassic();
const cmd = new Int8Array(2);
cmd[0] = 0x30; // 读取指令
cmd[1] = block; // 块号
return new Promise((resolve, reject) => {
mifare.transceive({
data: cmd.buffer,
success: (res) => {
const bytes = new Uint8Array(res.data);
const hexStr = Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join(':');
resolve(hexStr);
},
fail: reject
});
});
}
写入操作比读取更复杂,需要注意:
这是我封装的安全写入方法:
javascript复制function writeBlock(block, data) {
if (block % 4 === 3) {
throw new Error('不能直接写控制块');
}
if (data.length !== 16) {
throw new Error('数据长度必须为16字节');
}
const mifare = adapter.getMifareClassic();
const cmd = new Int8Array(18);
cmd[0] = 0xA0; // 写入指令
cmd[1] = block; // 块号
// 填充数据
for (let i = 0; i < 16; i++) {
cmd[i+2] = data[i];
}
return new Promise((resolve, reject) => {
mifare.transceive({
data: cmd.buffer,
success: resolve,
fail: reject
});
});
}
假设我们要开发一个会员卡系统,可以这样设计数据结构:
对应的读写方法如下:
javascript复制async function readMemberCard(keyA, keyB) {
// 认证扇区0
await authenticateSector(0, 'A', keyA);
// 读取三个块
const [block0, block1, block2] = await Promise.all([
readBlock(0),
readBlock(1),
readBlock(2)
]);
// 解析数据
return {
cardNo: block0.substr(0, 16).replace(/:/g, ''),
phone: block0.substr(17, 16).replace(/:/g, ''),
points: parseInt(block1.substr(0, 8).replace(/:/g, ''), 16),
registerTime: new Date(parseInt(block1.substr(9, 16).replace(/:/g, ''), 16)),
expireDate: new Date(parseInt(block2.substr(0, 16).replace(/:/g, ''), 16))
};
}
在实际项目中,完整的NFC操作流程应该是这样的:
这里有个重要细节:每次操作完成后,应该调用mifare.close()释放资源,否则下次操作可能会失败。我在实际开发中就遇到过连续操作时的资源占用问题,后来增加了完善的资源释放逻辑:
javascript复制async function operateCard() {
const mifare = adapter.getMifareClassic();
try {
// 各种NFC操作...
} catch (err) {
console.error('操作失败', err);
} finally {
mifare.close();
adapter.stopDiscovery();
}
}
在开发过程中,我总结了一些常见错误和解决方法:
NFC适配器启动失败
认证失败
数据读写异常
为了更方便调试NFC功能,我建议:
这是我常用的调试代码片段:
javascript复制function debugHex(buffer, prefix = '') {
const view = new Uint8Array(buffer);
const hex = Array.from(view)
.map(b => b.toString(16).padStart(2, '0'))
.join(':');
console.log(prefix, hex);
return hex;
}
// 在transceive调用前后添加调试信息
mifare.transceive({
data: cmd.buffer,
success: (res) => {
debugHex(res.data, '接收数据:');
},
fail: (err) => {
console.error('操作失败', err);
}
});
NFC操作有几个耗时点需要注意:
对于需要频繁操作的场景,我有几个优化建议:
虽然M1卡广泛应用,但它的安全性已经不如现代芯片。在重要场景使用时要注意:
在最近的一个门禁系统项目中,我们就遇到了使用默认密钥的安全隐患,后来通过以下措施增强了安全性:
javascript复制// 使用动态密钥派生算法
function deriveKey(baseKey, uid) {
// 基于UID和主密钥生成扇区专属密钥
// 实际项目应该使用更安全的算法
const mixed = baseKey + uid.substr(0, 8);
return crypto.createHash('md5').update(mixed).digest('hex').substr(0, 12);
}
开发微信小程序的NFC功能确实需要踩不少坑,特别是M1卡的各种特殊情况处理。但一旦掌握了核心流程,就能实现各种有趣的物联网应用。我在实际项目中还遇到过更复杂的情况,比如多扇区操作、密钥滚动更新等,这些高级话题以后有机会再分享。如果你在开发过程中遇到特别棘手的问题,不妨检查下访问控制位的设置,这往往是问题的关键所在。