iT邦幫忙

0

[iOS] 用 CoreNFC 讀取 Suica 實體卡:一份實測踩坑指南

  • 分享至 

  • xImage
  •  

前言

我是一名獨立開發者,最近在做一個日韓旅遊記帳 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 的層級結構:

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 交通系)底下。


坑 1:FFFF 萬用碼被 Apple 拒絕

FeliCa 規格中有個萬用 system code FFFF,理論上可以匹配任何系統。

直覺反應:在 Info.plist 放一個 FFFF 不就什麼卡都能讀了?

結果 Apple 直接拒絕:

Invalid system code entry: FFFF

CoreNFC 要求你明確列舉每一個要偵測的 system code。沒有捷徑。


坑 2:88B4 超時,拖垮整個 RF 連線

照網路資料,把 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 的順序竟然會影響連線穩定性。


坑 3:實測 3 張 Suica,全部沒有 88B4

加入更多 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 的結果。


坑 4:服務藏在 System 0003

既然沒有 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

這是整個研究最關鍵的發現。


坑 5:Info.plist 順序決定一切

理解了 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

Continuation 安全:避免 double-resume crash

CoreNFC 的 delegate-based API 需要用 CheckedContinuation 橋接到 async/await。但 NFC session 的 lifecycle 有個陷阱:

session.invalidate() 成功後,仍然會觸發 didInvalidateWithError delegate。

如果在 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 不要假設,實測為準

Debug 技巧

Console.app 觀察 nfcd

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 協助寫作。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言