我是一名獨立開發者,最近在做一個日韓旅遊記帳 App。收到用戶許願:
「可以用 NFC 感應西瓜卡直接記帳嗎?不想每次都手打金額。」
聽起來可行 — iPhone 有 CoreNFC,Suica 是 FeliCa 卡片,理論上能讀。於是我開始研究。
查了一輪資料,網路上幾乎都說 Suica 的 FeliCa system code 是 88B4。實測結果完全不同。
這篇文章記錄了整個踩坑過程,包含 nfcd daemon 的行為觀察、實測數據、以及最終的讀取策略。希望能幫到同樣在研究 CoreNFC + FeliCa 的開發者。
環境:iPhone 16 Pro / iOS 26.2 / Xcode 26.1.1 / CoreNFC
在進入踩坑之前,先簡單介紹 FeliCa 的層級結構:
FeliCa 卡片
├── System(系統)— 由 system code 識別(如 88B4、0003)
│ ├── Service(服務)— 由 service code 識別(如 0x008B)
│ │ └── Block(資料區塊)— 每個 16 bytes
│ └── Service ...
└── System ...
一張卡片可以有多個 System,每個 System 下有多個 Service,每個 Service 下有多個 Block。讀取資料的關鍵是:找到正確的 System + Service 組合。
根據網路資料,日本交通 IC 卡的標準配置是:
| Service Code | 用途 | 說明 |
|---|---|---|
0x008B |
餘額 | 1 block(16 bytes),bytes 10-11 為 little-endian 金額 |
0x090F |
交易歷史 | 最多 20 blocks,每筆交易 16 bytes |
這些 service codes 普遍記載在 system 88B4(Cybernetics 交通系)底下。
FeliCa 規格中有個萬用 system code FFFF,理論上可以匹配任何系統。
直覺反應:在 Info.plist 放一個 FFFF 不就什麼卡都能讀了?
結果 Apple 直接拒絕:
Invalid system code entry: FFFF
CoreNFC 要求你明確列舉每一個要偵測的 system code。沒有捷徑。
照網路資料,把 88B4 放進 Info.plist:
<key>com.apple.developer.nfc.readersession.felica.systemcodes</key>
<array>
<string>88B4</string>
</array>
啟動 NFC session,靠卡 — 沒反應。
打開 macOS 的 Console.app,過濾 nfcd process,看到:
nfcd: _getIDMFromTag:systemCode:
nfcd: phNciNfc_RspTimeOutCb: Timer expired before data is received!
nfcd: Target Lost!!
nfcd: Failed to get IDM and PMM with 0 retries
nfcd 是 iOS 的 NFC daemon,負責底層 RF 通訊。它會按照 Info.plist 中的順序,逐一用每個 system code 呼叫 _getIDMFromTag:systemCode: 嘗試連線。
重點是:超時不只是「查詢失敗」,它會弱化整個 RF 連線。後面即使有其他 system code 成功連上,App 層也只能送 1-2 個 FeliCa 指令就斷線(Tag connection lost)。
這是整個開發過程中最反直覺的發現 — Info.plist 的順序竟然會影響連線穩定性。
加入更多 system codes 後,終於連上了卡片。接著用 requestSystemCode() 查詢卡片實際支援的系統碼:
let systemCodes = try await tag.requestSystemCode()
// 結果:["0003", "FE00", "86A7"]
測了 3 張不同年份購入的實體 Suica 卡,結果一樣:
卡片 A → ["0003", "FE00", "86A7"]
卡片 B → ["0003", "FE00", "86A7"]
卡片 C → ["0003", "FE00", "86A7"]
沒有一張有 88B4。
目前不確定是所有實體 Suica 都這樣,還是跟卡片世代有關。手邊沒有 PASMO 或 Mobile Suica 可以交叉驗證。如果你手上有不同卡片,歡迎留言分享 requestSystemCode 的結果。
既然沒有 88B4,那餘額和交易歷史在哪?
用 requestService 逐一探測每個 system 底下的 service codes:
// 探測 service 是否存在
let versions = try await tag.requestService(
nodeCodeList: [serviceData]
)
// 回傳 FFFF = 不存在,其他值 = 存在
結果:
System 0003:
0x008B (餘額): ✅ version=0300(存在)
0x090F (歷史): ✅ version=0300(存在)
System 86A7:
0x008B (餘額): ❌ FFFF(不存在)
0x090F (歷史): ❌ FFFF(不存在)
其他探測: 全部 FFFF
System FE00:
Common Area,沒有交通相關服務
交通卡服務的 service codes 跟網路資料一樣(0x008B / 0x090F),但掛載的 system 不是 88B4 而是 0003。
這是整個研究最關鍵的發現。
理解了 nfcd 的行為後,解法就很清楚了:
把服務所在的 system code 排在 Info.plist 最前面。
<array>
<string>0003</string> <!-- 服務在這,排最前 -->
<string>FE00</string> <!-- Common Area fallback -->
<string>88B4</string> <!-- 標準交通卡(備用) -->
<string>86A7</string> <!-- 無有用服務,排最後 -->
</array>
nfcd 嘗試 0003 → 第一個就成功 → RF 連線完整 → App 可以穩定送出多個讀取指令。
連線穩定後,讀取邏輯就比較直覺了。
策略 1(快速路徑):
nfcd 連到 0003 → 直接 readWithoutEncryption → 成功
NFC 指令數: 最少,最穩定
策略 2(fallback):
nfcd 連到其他系統 → 直接讀取失敗
→ requestSystemCode → polling 切換到正確系統 → 重試
let (_, _, balanceBlocks) = try await tag.readWithoutEncryption(
serviceCodeList: [Data([0x8B, 0x00])], // 0x008B, little-endian
blockList: Data([0x80, 0x00]) // block 0
)
// bytes 10-11, little-endian UInt16 → 日圓金額
FeliCa Lite-S 每次最多讀 4 blocks,所以需要分批:
// 分批讀取,每次最多 4 blocks
// block 0 = 最新, block 19 = 最舊
for startBlock in stride(from: 0, to: 20, by: 4) {
let endBlock = min(startBlock + 4, 20)
let blockCount = endBlock - startBlock
var blockListData = Data()
for i in startBlock..<endBlock {
blockListData.append(contentsOf: [0x80, UInt8(i)])
}
let (_, _, blocks) = try await tag.readWithoutEncryption(
serviceCodeList: [historyServiceData],
blockList: blockListData
)
// 每 16 bytes 為一筆交易
}
每筆交易 16 bytes,格式如下:
Byte 0: 終端機類型(改札機 0x05, 超商 0x12, 自販機 0x16 ...)
Byte 1: 處理類型(車資 0x01, 儲值 0x02, 消費購物 0x46 ...)
Bytes 4-5: 日期(big-endian UInt16)
→ 7 bits 年 + 4 bits 月 + 5 bits 日,基準年 2000
Bytes 6-7: 進站代碼
Bytes 8-9: 出站代碼
Bytes 10-11: 餘額(little-endian UInt16,日圓)
日期解析範例:
let rawDate = UInt16(data[4]) << 8 | UInt16(data[5])
let year = Int(rawDate >> 9) + 2000 // 上位 7 bits
let month = Int((rawDate >> 5) & 0x0F) // 中間 4 bits
let day = Int(rawDate & 0x1F) // 下位 5 bits
CoreNFC 的 delegate-based API 需要用 CheckedContinuation 橋接到 async/await。但 NFC session 的 lifecycle 有個陷阱:
session.invalidate()成功後,仍然會觸發didInvalidateWithErrordelegate。
如果在 didDetect 成功讀取後呼叫 invalidate(),continuation 已經 resume 過一次,delegate 再次嘗試 resume 就會 crash。
解法是加一個 flag:
private var hasResumed = false
private func resumeOnce(returning value: SuicaCardData) {
guard !hasResumed else { return }
hasResumed = true
continuation?.resume(returning: value)
continuation = nil
}
private func resumeOnce(throwing error: Error) {
guard !hasResumed else { return }
hasResumed = true
continuation?.resume(throwing: error)
continuation = nil
}
成功讀取餘額 + 最近 20 筆交易紀錄。整個流程:靠卡 → 顯示交易清單 → 勾選要記帳的項目 → 一鍵匯入。
交通和超商消費會根據終端機類型自動分類,儲值紀錄自動排除(資金轉移不算消費)。
| # | 陷阱 | 解法 |
|---|---|---|
| 1 | FFFF 萬用碼 |
Apple 拒絕,必須列舉具體 system codes |
| 2 | 88B4 超時拖垮 RF | 把最可能成功的 code 排最前 |
| 3 | ISO7816 支付卡 AID | 被 nfcd 標記 Non-permissible,不要加 |
| 4 | NDEF vs Tag Reader 混淆 | Suica 不是 NDEF,必須用 NFCTagReaderSession |
| 5 | FeliCa Lite-S 每次最多 4 blocks | 分批讀取 |
| 6 | Continuation double-resume | 加 hasResumed flag 保護 |
| 7 | requestSystemCode 回傳的不一定有 88B4 |
不要假設,實測為準 |
macOS Console.app,Wi-Fi 連接 iPhone,過濾 process nfcd:
Polling cfg: A:0 B:0 F:1 → 確認 FeliCa polling 啟動_getIDMFromTag:systemCode: → system code 查詢結果handleRemoteTagsDetected → 硬體偵測結果Target Lost!! → RF 連線中斷這是理解 NFC 底層行為最有效的工具。很多問題在 App 層看不到,但 nfcd 的 log 會清楚告訴你發生了什麼。
這次最大的收穫是:不要盡信網路資料,實測才是真的。
88B4 在我手上的 3 張 Suica 實體卡都不存在,但 service codes(0x008B / 0x090F)是一樣的,只是掛在不同的 system 上。
CoreNFC + FeliCa 的中文資源真的很少。希望這篇能成為一個有用的參考點。
如果你有不同型號的 IC 卡(PASMO、ICOCA、Mobile Suica),歡迎留言分享 requestSystemCode 的結果,讓我們一起補全這張地圖。
我是 Ervis,獨立開發者,目前在做旅日記帳 App「旅收」。如果你對 iOS 開發或獨立開發有興趣,歡迎追蹤交流。
PS: 有使用 AI 協助寫作。