顯示具有 嵌入式 標籤的文章。 顯示所有文章
顯示具有 嵌入式 標籤的文章。 顯示所有文章

Arduino 遇上 Protocol Buffers:當微控制器也要說「高效語言」

Arduino 電路實戰

那是去年夏天,我們要建置一個環境監測系統,用 20 個 ESP8266 節點收集工廠各角落的溫濕度資料。看起來很簡單對吧?用 JSON 格式,HTTP POST 到雲端,搞定!

結果事情沒那麼順利。每個感測器每分鐘上傳一次資料,WiFi 網路很快就被塞爆了。更糟糕的是,有些 ESP8266 因為 JSON 序列化耗用太多記憶體,經常出現重啟的狀況。

當時的我第一次深深體會到:在微控制器的世界裡,每個位元組都很珍貴。

後來一位資深同事建議我試試 Protocol Buffers,我當時的反應是:"這玩意不是給大型系統用的嗎?Arduino 這種小板子能跑得動?"

事實證明,不但跑得動,而且效果驚人。同樣的資料,傳輸量減少了 60%,記憶體使用量降低 40%,系統再也沒有當機過。

今天我想分享這個改變我對嵌入式開發認知的技術:如何在 Arduino 和單晶片上使用 Protocol Buffers。

為什麼微控制器需要「瘦身」的數據格式?

在聊技術實作之前,我們先理解一下為什麼要在資源受限的環境中使用 Protocol Buffers。

Arduino 的現實限制

拿最常見的 Arduino Uno 來說:

  • SRAM:只有 2KB
  • Flash Memory:32KB(還要扣除 bootloader)
  • 處理器:16MHz 的 8位元 AVR

這意味著什麼?一個 JSON 字串可能就佔用了你 10% 的記憶體!

我實際測試過一個簡單的溫濕度讀取:

{
  "device_id": "sensor_001",
  "timestamp": 1640995200,
  "temperature": 25.6,
  "humidity": 60.3,
  "battery": 87
}

這個 JSON 就要 98 bytes。如果你的設備每分鐘上傳一次資料,光是緩衝幾筆資料就可能讓記憶體吃緊。

網路頻寬的珍貴

在 IoT 場景中,很多設備使用的是:

  • WiFi:訊號可能不穩定,傳輸失敗需要重送
  • 3G/4G:按流量計費,每 MB 都是錢
  • LoRa/NB-IoT:傳輸速度慢,資料包大小有嚴格限制

每節省一個位元組,都直接影響系統的穩定性和運營成本。

電池壽命的考量

許多 IoT 設備需要電池供電數月甚至數年。更小的資料包意味著:

  • 更短的傳輸時間
  • 更少的 CPU 運算
  • 更長的電池壽命

認識 nanopb:微控制器的專屬 Protocol Buffers

IoT 系統架構

Google 官方的 Protocol Buffers 庫對 Arduino 來說太重了,這時候 nanopb 就是我們的救星。

nanopb 的特色

nanopb 是專門為嵌入式系統設計的 Protocol Buffers 實作,它有以下特點:

  • 極小的程式碼體積:通常只需要幾 KB 的 Flash 空間
  • 純 C 語言:沒有動態記憶體分配,執行效率高
  • 靜態緩衝區:編譯時就確定記憶體使用量
  • 跨平台相容:與標準 Protocol Buffers 完全相容

實際大小比較

讓我用實際數據告訴你差異有多大:

相同的感測器資料:

格式 大小 記憶體使用 序列化時間
JSON 98 bytes 200 bytes 12ms
nanopb 24 bytes 80 bytes 3ms

你看,同樣的資料,nanopb 只用了 JSON 四分之一的大小!

實戰項目:打造你的第一個 protobuf 感測器

現在讓我們動手實作一個實際的專案:使用 ESP8266 + DHT22 感測器,透過 nanopb 上傳溫濕度資料。

硬體準備

你需要:

  • ESP8266 開發板(如 NodeMCU)
  • DHT22 溫濕度感測器
  • 4.7kΩ 電阻
  • 麵包板和跳線

接線圖:

  • DHT22 VCC → ESP8266 3.3V
  • DHT22 GND → ESP8266 GND
  • DHT22 DATA → ESP8266 D4(GPIO2)
  • 4.7kΩ 電阻連接 VCC 和 DATA

步驟一:定義 Protocol Buffers Schema

首先建立 sensor.proto 檔案:

syntax = "proto3";

message SensorReading {
    string device_id = 1;
    int64 timestamp = 2;
    float temperature = 3;
    float humidity = 4;
    int32 battery_level = 5;
    bool status_ok = 6;
}

步驟二:生成 nanopb C 代碼

下載 nanopb 工具後,執行:

python nanopb_generator.py sensor.proto

這會生成兩個檔案:

  • sensor.pb.h:標頭檔
  • sensor.pb.c:實作檔

生成的結構看起來像這樣:

typedef struct _SensorReading {
    char device_id[32];
    int64_t timestamp;
    float temperature;
    float humidity;
    int32_t battery_level;
    bool status_ok;
} SensorReading;

步驟三:Arduino 程式實作

#include <WiFi.h>
#include <HTTPClient.h>
#include <DHT.h>
#include <pb_encode.h>
#include <pb_decode.h>
#include "sensor.pb.h"

// WiFi 設定
const char* ssid = "your_wifi_ssid";
const char* password = "your_wifi_password";
const char* serverURL = "http://your-server.com/api/sensor";

// DHT 感測器設定
#define DHT_PIN 2
#define DHT_TYPE DHT22
DHT dht(DHT_PIN, DHT_TYPE);

// protobuf 緩衝區
uint8_t buffer[128];
size_t message_length;

void setup() {
    Serial.begin(115200);
    dht.begin();
    
    // 連接 WiFi
    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED) {
        delay(1000);
        Serial.println("Connecting to WiFi...");
    }
    Serial.println("WiFi connected!");
}

void loop() {
    // 讀取感測器資料
    float temp = dht.readTemperature();
    float hum = dht.readHumidity();
    
    if (isnan(temp) || isnan(hum)) {
        Serial.println("Failed to read from DHT sensor!");
        delay(5000);
        return;
    }
    
    // 建立 protobuf 訊息
    SensorReading reading = SensorReading_init_zero;
    strcpy(reading.device_id, "esp8266_001");
    reading.timestamp = WiFi.getTime();  // 需要設定 NTP
    reading.temperature = temp;
    reading.humidity = hum;
    reading.battery_level = analogRead(A0);  // 假設連接電池檢測電路
    reading.status_ok = true;
    
    // 序列化為 protobuf 格式
    pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer));
    bool status = pb_encode(&stream, SensorReading_fields, &reading);
    message_length = stream.bytes_written;
    
    if (!status) {
        Serial.println("Failed to encode protobuf message");
        return;
    }
    
    // 發送到伺服器
    sendToServer(buffer, message_length);
    
    // 深度睡眠 5 分鐘(節省電力)
    Serial.println("Going to deep sleep...");
    ESP.deepSleep(5 * 60 * 1000000);  // 5 分鐘,單位是微秒
}

void sendToServer(uint8_t* data, size_t length) {
    if (WiFi.status() == WL_CONNECTED) {
        HTTPClient http;
        http.begin(serverURL);
        http.addHeader("Content-Type", "application/x-protobuf");
        
        int httpResponseCode = http.POST(data, length);
        
        if (httpResponseCode > 0) {
            String response = http.getString();
            Serial.printf("HTTP Response: %d\n", httpResponseCode);
            Serial.println("Data sent successfully!");
        } else {
            Serial.printf("Error sending data: %d\n", httpResponseCode);
        }
        
        http.end();
    } else {
        Serial.println("WiFi not connected");
    }
}

步驟四:伺服器端接收

簡單的 Python 伺服器範例:

from flask import Flask, request
import sensor_pb2  # 由 protoc 生成

app = Flask(__name__)

@app.route('/api/sensor', methods=['POST'])
def receive_sensor_data():
    try:
        # 解析 protobuf 資料
        reading = sensor_pb2.SensorReading()
        reading.ParseFromString(request.data)
        
        print(f"Device: {reading.device_id}")
        print(f"Temperature: {reading.temperature}°C")
        print(f"Humidity: {reading.humidity}%")
        print(f"Battery: {reading.battery_level}%")
        
        # 這裡可以存入資料庫或進行其他處理
        
        return "OK", 200
    except Exception as e:
        print(f"Error: {e}")
        return "Error", 400

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

性能測試與實際數據

性能對比圖

我做了詳細的性能測試,比較 JSON 和 nanopb 在 ESP8266 上的表現:

資料大小比較

單筆感測器讀取資料:

欄位 JSON nanopb 節省比例
device_id "esp8266_001" (12 bytes) field_tag + string (13 bytes) -8%
timestamp 1640995200 (10 bytes) varint (5 bytes) +50%
temperature 25.6 (4 bytes) fixed32 (5 bytes) -25%
humidity 60.3 (4 bytes) fixed32 (5 bytes) -25%
battery_level 87 (2 bytes) varint (2 bytes) 0%
status_ok true (4 bytes) bool (2 bytes) +50%
總計 98 bytes 37 bytes +62%

記憶體使用量測試

使用 ESP8266 的記憶體監控功能,我測量了實際的記憶體使用:

void printMemoryUsage() {
    uint32_t free_heap = ESP.getFreeHeap();
    uint32_t max_free_block = ESP.getMaxFreeBlockSize();
    
    Serial.printf("Free heap: %d bytes\n", free_heap);
    Serial.printf("Max free block: %d bytes\n", max_free_block);
}

測試結果:

操作 JSON 記憶體使用 nanopb 記憶體使用 節省
序列化 250 bytes 100 bytes 60%
網路傳輸緩衝 150 bytes 60 bytes 60%
總記憶體需求 400 bytes 160 bytes 60%

處理時間測試

unsigned long start_time, end_time;

// JSON 序列化測試
start_time = micros();
String json = createJSONString(temp, hum, battery);
end_time = micros();
Serial.printf("JSON serialization: %lu μs\n", end_time - start_time);

// nanopb 序列化測試
start_time = micros();
pb_encode(&stream, SensorReading_fields, &reading);
end_time = micros();
Serial.printf("nanopb serialization: %lu μs\n", end_time - start_time);

測試結果:

  • JSON 序列化:平均 8,500 μs
  • nanopb 序列化:平均 2,100 μs
  • nanopb 快了 75%!

進階應用:多感測器智能監測網路

讓我們把視野放得更大一點,設計一個更複雜的系統。

場景設計

假設你要監測一個溫室,需要收集:

  • 多個位置的溫濕度
  • 土壤濕度
  • 光照強度
  • 二氧化碳濃度

彈性的 Schema 設計

syntax = "proto3";

message SensorReading {
    string device_id = 1;
    int64 timestamp = 2;
    string location = 3;
    
    // 使用 oneof 讓一個訊息可以包含不同類型的資料
    oneof sensor_data {
        TemperatureHumidity temp_hum = 10;
        SoilMoisture soil = 11;
        LightLevel light = 12;
        CO2Level co2 = 13;
    }
}

message TemperatureHumidity {
    float temperature = 1;
    float humidity = 2;
}

message SoilMoisture {
    float moisture_percent = 1;
    float ph_level = 2;
}

message LightLevel {
    int32 lux = 1;
    string spectrum = 2;  // "full", "uv", "ir"
}

message CO2Level {
    int32 ppm = 1;
    bool alarm_triggered = 2;
}

MQTT 整合

在大型 IoT 系統中,MQTT 是更好的選擇:

#include <PubSubClient.h>

WiFiClient espClient;
PubSubClient client(espClient);

const char* mqtt_server = "your-mqtt-broker.com";
const char* mqtt_topic = "greenhouse/sensors";

void setup() {
    // ... 其他初始化代碼 ...
    
    client.setServer(mqtt_server, 1883);
    connectToMQTT();
}

void connectToMQTT() {
    while (!client.connected()) {
        Serial.print("Attempting MQTT connection...");
        String clientId = "ESP8266Client-";
        clientId += String(random(0xffff), HEX);
        
        if (client.connect(clientId.c_str())) {
            Serial.println("connected");
        } else {
            Serial.print("failed, rc=");
            Serial.print(client.state());
            Serial.println(" try again in 5 seconds");
            delay(5000);
        }
    }
}

void publishSensorData(uint8_t* data, size_t length) {
    if (!client.connected()) {
        connectToMQTT();
    }
    
    bool result = client.publish(mqtt_topic, data, length);
    if (result) {
        Serial.println("Data published successfully");
    } else {
        Serial.println("Failed to publish data");
    }
}

踩坑經驗與優化技巧

在實際使用過程中,我遇到了不少坑,這裡分享一些寶貴經驗。

坑一:字串長度限制

問題:nanopb 預設的字串長度限制可能不夠用。

解決方案:在 .options 檔案中指定最大長度:

# sensor.options
SensorReading.device_id max_size:32
SensorReading.location max_size:64

然後重新生成程式碼:

python nanopb_generator.py sensor.proto

坑二:浮點數精度問題

問題:有些感測器讀取的值有很多小數點,但實際上不需要這麼高精度。

解決方案:在傳輸前量化數值:

// 將溫度量化到 0.1 度精度
int32_t quantized_temp = (int32_t)(temperature * 10);
reading.temperature = quantized_temp / 10.0f;  // 或者直接使用整數欄位

更好的方案:直接使用整數:

message SensorReading {
    string device_id = 1;
    int64 timestamp = 2;
    int32 temperature_x10 = 3;  // 實際溫度乘以 10
    int32 humidity_x10 = 4;     // 實際濕度乘以 10
}

這樣可以進一步減小資料大小,因為整數的 varint 編碼更有效率。

坑三:記憶體碎片化

問題:頻繁的序列化/反序列化可能導致記憶體碎片化。

解決方案:使用靜態緩衝區池:

// 預分配多個緩衝區
#define BUFFER_COUNT 3
#define BUFFER_SIZE 128

static uint8_t buffer_pool[BUFFER_COUNT][BUFFER_SIZE];
static int current_buffer = 0;

uint8_t* getNextBuffer() {
    uint8_t* buffer = buffer_pool[current_buffer];
    current_buffer = (current_buffer + 1) % BUFFER_COUNT;
    return buffer;
}

坑四:WiFi 連線不穩定

問題:在網路不穩定的環境下,資料傳輸容易失敗。

解決方案:實作本地緩存和重試機制:

#include <SPIFFS.h>

void saveDataToLocal(uint8_t* data, size_t length) {
    String filename = "/data_" + String(millis()) + ".pb";
    File file = SPIFFS.open(filename, "w");
    if (file) {
        file.write(data, length);
        file.close();
        Serial.println("Data saved locally: " + filename);
    }
}

void uploadPendingData() {
    Dir dir = SPIFFS.openDir("/");
    while (dir.next()) {
        if (dir.fileName().startsWith("data_")) {
            File file = dir.openFile("r");
            if (file) {
                size_t length = file.size();
                uint8_t* buffer = new uint8_t[length];
                file.readBytes((char*)buffer, length);
                file.close();
                
                if (sendToServer(buffer, length)) {
                    // 傳送成功,刪除本地檔案
                    SPIFFS.remove(dir.fileName());
                    Serial.println("Uploaded and deleted: " + dir.fileName());
                }
                
                delete[] buffer;
            }
        }
    }
}

效能調優秘訣

1. 選擇合適的數值類型

// 不好:使用 int64 存儲小數值
int64 sensor_id = 1;  // 浪費空間

// 好:使用合適的類型
int32 sensor_id = 1;  // 對大多數應用已經足夠

2. 批次傳輸

與其每次讀取就傳輸一次,不如累積幾筆資料一起傳:

message SensorBatch {
    string device_id = 1;
    repeated SensorReading readings = 2;
}

3. 壓縮大型資料

對於某些場景(如音訊資料、影像資料),可以在 protobuf 層面再加壓縮:

#include <ArduinoLZ77.h>  // 輕量級壓縮庫

void compressAndSend(uint8_t* data, size_t length) {
    uint8_t compressed[256];
    size_t compressed_size = lz77_compress(data, length, compressed, sizeof(compressed));
    
    if (compressed_size < length) {
        // 壓縮有效,使用壓縮資料
        sendToServer(compressed, compressed_size);
    } else {
        // 壓縮無效,使用原始資料
        sendToServer(data, length);
    }
}

除錯與測試技巧

開發過程中,除錯是不可避免的,這裡分享一些實用技巧。

序列化資料檢查

void printProtobufData(uint8_t* data, size_t length) {
    Serial.print("Protobuf data (");
    Serial.print(length);
    Serial.print(" bytes): ");
    
    for (size_t i = 0; i < length; i++) {
        if (data[i] < 16) Serial.print("0");
        Serial.print(data[i], HEX);
        Serial.print(" ");
    }
    Serial.println();
}

反序列化測試

bool testSerialization() {
    // 建立測試資料
    SensorReading original = SensorReading_init_zero;
    strcpy(original.device_id, "test_device");
    original.temperature = 25.5f;
    original.humidity = 60.0f;
    
    // 序列化
    uint8_t buffer[128];
    pb_ostream_t ostream = pb_ostream_from_buffer(buffer, sizeof(buffer));
    bool encode_status = pb_encode(&ostream, SensorReading_fields, &original);
    
    if (!encode_status) {
        Serial.println("Encoding failed");
        return false;
    }
    
    // 反序列化
    SensorReading decoded = SensorReading_init_zero;
    pb_istream_t istream = pb_istream_from_buffer(buffer, ostream.bytes_written);
    bool decode_status = pb_decode(&istream, SensorReading_fields, &decoded);
    
    if (!decode_status) {
        Serial.println("Decoding failed");
        return false;
    }
    
    // 驗證資料
    bool success = (strcmp(original.device_id, decoded.device_id) == 0) &&
                   (fabs(original.temperature - decoded.temperature) < 0.01f) &&
                   (fabs(original.humidity - decoded.humidity) < 0.01f);
    
    if (success) {
        Serial.println("Serialization test passed");
    } else {
        Serial.println("Serialization test failed");
    }
    
    return success;
}

記憶體洩漏檢查

void memoryLeakTest() {
    uint32_t initial_free = ESP.getFreeHeap();
    Serial.printf("Initial free heap: %d bytes\n", initial_free);
    
    // 執行 1000 次序列化操作
    for (int i = 0; i < 1000; i++) {
        SensorReading reading = SensorReading_init_zero;
        strcpy(reading.device_id, "test");
        reading.temperature = i % 100;
        
        uint8_t buffer[128];
        pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer));
        pb_encode(&stream, SensorReading_fields, &reading);
        
        if (i % 100 == 0) {
            uint32_t current_free = ESP.getFreeHeap();
            Serial.printf("Iteration %d, free heap: %d bytes\n", i, current_free);
        }
    }
    
    uint32_t final_free = ESP.getFreeHeap();
    Serial.printf("Final free heap: %d bytes\n", final_free);
    Serial.printf("Memory change: %d bytes\n", (int32_t)final_free - (int32_t)initial_free);
}

實際專案案例:智能植栽監控系統

讓我分享一個完整的實際專案,展示如何在真實環境中應用這些技術。

專案背景

我幫朋友設計了一個智能植栽監控系統,用於管理他的小型有機農場。系統需要:

  • 24/7 監控土壤濕度、光照、溫濕度
  • 自動灌溉控制
  • 手機 App 即時查看
  • 低功耗運行(太陽能供電)
  • 資料歷史記錄和分析

系統架構

感測器節點 → WiFi → MQTT Broker → 雲端伺服器 → 手機 App
     ↓
  SD 卡備份

完整的 Proto Schema

syntax = "proto3";

// 感測器讀取資料
message SensorReading {
    string node_id = 1;
    int64 timestamp = 2;
    string location = 3;
    
    // 環境資料
    float temperature = 10;
    float humidity = 11;
    int32 light_lux = 12;
    
    // 土壤資料
    float soil_moisture = 20;
    float soil_temperature = 21;
    float ph_level = 22;
    
    // 系統狀態
    float battery_voltage = 30;
    int32 signal_strength = 31;
    bool pump_active = 32;
    
    // 錯誤和警告
    repeated string warnings = 40;
}

// 控制命令
message ControlCommand {
    string target_node = 1;
    int64 timestamp = 2;
    
    oneof command {
        PumpControl pump = 10;
        ConfigUpdate config = 11;
        SystemCommand system = 12;
    }
}

message PumpControl {
    bool enable = 1;
    int32 duration_seconds = 2;
}

message ConfigUpdate {
    int32 reading_interval = 1;
    float moisture_threshold = 2;
    bool auto_irrigation = 3;
}

message SystemCommand {
    enum Command {
        RESTART = 0;
        DEEP_SLEEP = 1;
        FACTORY_RESET = 2;
        UPDATE_FIRMWARE = 3;
    }
    Command command = 1;
}

Arduino 節點程式(簡化版)

#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
#include <pb_encode.h>
#include <pb_decode.h>
#include "plant_monitor.pb.h"

// 硬體定義
#define DHT_PIN 4
#define DHT_TYPE DHT22
#define SOIL_MOISTURE_PIN A0
#define PUMP_RELAY_PIN 5
#define BATTERY_PIN A1

// 感測器物件
DHT dht(DHT_PIN, DHT_TYPE);
WiFiClient espClient;
PubSubClient mqtt(espClient);

// 設定參數
const char* wifi_ssid = "FarmWiFi";
const char* wifi_password = "your_password";
const char* mqtt_server = "farm-mqtt.example.com";
const char* node_id = "plant_node_001";

// 運行參數
int reading_interval = 300;  // 5分鐘
float moisture_threshold = 30.0;  // 30%
bool auto_irrigation = true;

void setup() {
    Serial.begin(115200);
    
    // 初始化硬體
    dht.begin();
    pinMode(PUMP_RELAY_PIN, OUTPUT);
    digitalWrite(PUMP_RELAY_PIN, LOW);
    
    // 連接網路
    connectWiFi();
    mqtt.setServer(mqtt_server, 1883);
    mqtt.setCallback(onMqttMessage);
    connectMQTT();
    
    Serial.println("Plant monitoring node started");
}

void loop() {
    if (!mqtt.connected()) {
        connectMQTT();
    }
    mqtt.loop();
    
    // 讀取感測器資料
    SensorReading reading = readAllSensors();
    
    // 檢查是否需要灌溉
    if (auto_irrigation && reading.soil_moisture < moisture_threshold) {
        activateIrrigation();
        reading.pump_active = true;
    }
    
    // 發送資料
    sendSensorData(reading);
    
    // 深度睡眠節省電力
    Serial.printf("Sleeping for %d seconds\n", reading_interval);
    ESP.deepSleep(reading_interval * 1000000);
}

SensorReading readAllSensors() {
    SensorReading reading = SensorReading_init_zero;
    
    // 基本資訊
    strcpy(reading.node_id, node_id);
    reading.timestamp = getUnixTime();
    strcpy(reading.location, "Greenhouse_A");
    
    // 環境感測器
    reading.temperature = dht.readTemperature();
    reading.humidity = dht.readHumidity();
    reading.light_lux = readLightSensor();
    
    // 土壤感測器
    reading.soil_moisture = readSoilMoisture();
    reading.soil_temperature = readSoilTemperature();
    reading.ph_level = readPHLevel();
    
    // 系統狀態
    reading.battery_voltage = readBatteryVoltage();
    reading.signal_strength = WiFi.RSSI();
    reading.pump_active = false;
    
    // 檢查警告
    checkWarnings(reading);
    
    return reading;
}

void sendSensorData(const SensorReading& reading) {
    uint8_t buffer[256];
    pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer));
    
    bool status = pb_encode(&stream, SensorReading_fields, &reading);
    if (!status) {
        Serial.println("Failed to encode sensor data");
        return;
    }
    
    // 發送到 MQTT
    bool sent = mqtt.publish("farm/sensors/data", buffer, stream.bytes_written);
    if (sent) {
        Serial.printf("Sent %d bytes of sensor data\n", stream.bytes_written);
    } else {
        Serial.println("Failed to send sensor data");
        // 保存到 SD 卡作為備份
        saveToSDCard(buffer, stream.bytes_written);
    }
}

void onMqttMessage(char* topic, byte* payload, unsigned int length) {
    if (strcmp(topic, "farm/control/commands") == 0) {
        // 解析控制命令
        ControlCommand command = ControlCommand_init_zero;
        pb_istream_t stream = pb_istream_from_buffer(payload, length);
        
        if (pb_decode(&stream, ControlCommand_fields, &command)) {
            processControlCommand(command);
        }
    }
}

void processControlCommand(const ControlCommand& command) {
    if (strcmp(command.target_node, node_id) != 0) {
        return;  // 不是給這個節點的命令
    }
    
    switch (command.which_command) {
        case ControlCommand_pump_tag:
            if (command.command.pump.enable) {
                activateIrrigation(command.command.pump.duration_seconds);
            } else {
                digitalWrite(PUMP_RELAY_PIN, LOW);
            }
            break;
            
        case ControlCommand_config_tag:
            // 更新配置
            reading_interval = command.command.config.reading_interval;
            moisture_threshold = command.command.config.moisture_threshold;
            auto_irrigation = command.command.config.auto_irrigation;
            Serial.println("Configuration updated");
            break;
            
        case ControlCommand_system_tag:
            switch (command.command.system.command) {
                case SystemCommand_Command_RESTART:
                    ESP.restart();
                    break;
                case SystemCommand_Command_DEEP_SLEEP:
                    ESP.deepSleep(0);
                    break;
                // ... 其他系統命令
            }
            break;
    }
}

成果展示

這個系統運行了一年多,效果非常好:

資料傳輸效率:

  • 平均每筆資料:45 bytes(protobuf)vs 180 bytes(JSON)
  • 每天傳輸資料:約 288 筆 × 45 bytes = 12.96 KB
  • 如果用 JSON:約 288 筆 × 180 bytes = 51.84 KB
  • 節省了 75% 的頻寬

電池續航力:

  • 使用 18650 鋰電池 + 太陽能板
  • 持續運行時間:夏季無限,冬季可達 2 週(無陽光)
  • 資料傳輸量減少直接延長了電池壽命

系統穩定性:

  • 運行 365 天,只有 3 次因為網路問題丟失資料
  • 本地 SD 卡備份機制確保資料不遺失
  • 記憶體使用量穩定,無內存洩漏

開發工具與環境建置

讓我分享一套完整的開發工具鏈,讓你快速上手。

工具安裝

1. 安裝 nanopb

# 從 GitHub 下載
git clone https://github.com/nanopb/nanopb.git
cd nanopb
git submodule update --init

# 建置生成器
cd generator/proto
make

# 設定環境變數
export PATH=$PATH:/path/to/nanopb/generator

2. Arduino IDE 設定

在 Arduino IDE 中安裝所需的庫:

  • PubSubClient(MQTT 客戶端)
  • DHT sensor library
  • ArduinoJson(如果需要 JSON 比較)

3. 建立專案結構

my_iot_project/
├── proto/
│   ├── sensor.proto
│   ├── sensor.options
│   └── generate.sh
├── arduino/
│   ├── main/
│   │   ├── main.ino
│   │   ├── sensor.pb.h
│   │   └── sensor.pb.c
│   └── libraries/
│       └── nanopb/
├── server/
│   ├── sensor_pb2.py
│   └── server.py
└── docs/
    └── README.md

自動化腳本

建立 generate.sh 簡化開發流程:

#!/bin/bash
# proto/generate.sh

echo "Generating nanopb files..."
python /path/to/nanopb/generator/nanopb_generator.py sensor.proto

echo "Copying to Arduino project..."
cp sensor.pb.h sensor.pb.c ../arduino/main/

echo "Generating Python files..."
protoc --python_out=../server sensor.proto

echo "Done!"

VS Code 擴展配置

建立 .vscode/tasks.json:

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "Generate Protobuf",
            "type": "shell",
            "command": "./proto/generate.sh",
            "group": "build",
            "presentation": {
                "echo": true,
                "reveal": "always",
                "focus": false,
                "panel": "shared"
            }
        }
    ]
}

這樣你就可以用 Ctrl+Shift+P → "Tasks: Run Task" → "Generate Protobuf" 快速生成程式碼。

常見問題與解決方案

Q1: nanopb 編譯錯誤

問題:Arduino IDE 報告 nanopb 相關的編譯錯誤。

解決:

  1. 確認 nanopb 版本相容性(建議使用 0.4.x 版本)
  2. 檢查 .options 檔案設定
  3. 確認生成的 .pb.h 和 .pb.c 檔案在正確位置
// 在 .ino 檔案開頭加入
#define PB_ENABLE_MALLOC 0  // 禁用動態記憶體分配
#define PB_FIELD_32BIT 1    // 支援大型訊息

Q2: 記憶體不足

問題:ESP8266 出現記憶體不足,設備重啟。

解決:

  1. 減少緩衝區大小
  2. 使用更精簡的 protobuf schema
  3. 實作記憶體池管理
// 使用更小的緩衝區
#define PROTOBUF_BUFFER_SIZE 64  // 而不是 256
uint8_t buffer[PROTOBUF_BUFFER_SIZE];

Q3: 資料解析失敗

問題:伺服器端無法解析 Arduino 發送的 protobuf 資料。

解決:

  1. 確認兩端使用相同的 .proto 檔案
  2. 檢查資料傳輸的 Content-Type
  3. 除錯序列化資料
// 除錯輸出
void debugProtobufData(uint8_t* data, size_t length) {
    Serial.printf("Protobuf hex dump (%d bytes):\n", length);
    for (int i = 0; i < length; i++) {
        Serial.printf("%02X ", data[i]);
        if ((i + 1) % 16 == 0) Serial.println();
    }
    Serial.println();
}

Q4: WiFi 連線問題

問題:在某些環境下 WiFi 連線不穩定。

解決:實作更強健的連線管理

void ensureWiFiConnection() {
    int retry_count = 0;
    const int max_retries = 10;
    
    while (WiFi.status() != WL_CONNECTED && retry_count < max_retries) {
        Serial.printf("WiFi connecting... (%d/%d)\n", retry_count + 1, max_retries);
        WiFi.begin(ssid, password);
        delay(5000);
        retry_count++;
    }
    
    if (WiFi.status() != WL_CONNECTED) {
        Serial.println("WiFi connection failed, entering deep sleep");
        ESP.deepSleep(60 * 1000000);  // 休眠 1 分鐘後重試
    }
}

未來發展與技術趨勢

邊緣計算整合

隨著 ESP32-S3 等更強大的微控制器出現,我們可以在設備端進行更多資料處理:

message ProcessedData {
    string device_id = 1;
    int64 timestamp = 2;
    
    // 原始資料的統計摘要
    StatisticalSummary temperature_stats = 10;
    StatisticalSummary humidity_stats = 11;
    
    // 異常檢測結果
    repeated AnomalyAlert anomalies = 20;
}

message StatisticalSummary {
    float mean = 1;
    float min = 2;
    float max = 3;
    float std_dev = 4;
    int32 sample_count = 5;
}

機器學習推論

未來的 IoT 設備可能會整合 TinyML,在本地進行簡單的機器學習推論:

message MLPrediction {
    string model_id = 1;
    int64 timestamp = 2;
    float confidence = 3;
    
    oneof prediction {
        PlantHealthPrediction plant_health = 10;
        WeatherForecast weather = 11;
        EquipmentFailure failure_risk = 12;
    }
}

5G 和低軌衛星

隨著 5G 和低軌衛星網路的普及,更多偏遠地區的 IoT 設備可以接入網路。Protocol Buffers 的高效率將變得更加重要,特別是在衛星通信的高延遲環境下。

最後的話:小設備,大智慧

回想起那個讓我頭痛一個月的專案,如果當時就知道 nanopb 這個神器,該省多少時間啊!

Protocol Buffers 在 Arduino 和單晶片上的應用,讓我深刻體會到:限制往往是創新的催化劑。正是因為有了記憶體、頻寬、電力的限制,我們才被迫去尋找更高效的解決方案。

在 IoT 的世界裡,每個位元組都有它的價值。當你的設備需要運行數月甚至數年,當你的網路頻寬只有幾 KB/s,當你的記憶體只有幾 KB 時,選擇正確的技術就變得至關重要。

nanopb 不只是一個工具,它代表了一種思維方式:如何在有限的資源下做出無限的可能。

給新手的建議

如果你是第一次嘗試在 Arduino 上使用 Protocol Buffers,我的建議是:

  1. 從小開始:先用最簡單的 schema,確保整個流程跑得通
  2. 測量一切:記憶體使用量、傳輸時間、資料大小都要實際測量
  3. 保持耐心:除錯嵌入式系統比除錯伺服器程式困難得多
  4. 記錄經驗:把遇到的問題和解決方案記錄下來,下次會用到

展望未來

物聯網正在快速發展,邊緣計算、人工智慧、5G 通信等技術都在重塑這個領域。但無論技術如何進步,資源效率始終是嵌入式系統的核心議題。

Protocol Buffers 為我們提供了一個優雅的解決方案,讓小小的 Arduino 也能說一口流利的「高效語言」。

希望這篇文章能夠幫助你在自己的 IoT 專案中更好地應用 Protocol Buffers。如果你有任何問題或想要分享你的經驗,歡迎留言討論!

記住:在 IoT 的世界裡,小設備也能有大智慧。


相關資源

官方文檔

教學資源

開發工具

硬體建議

  • 入門級:Arduino Uno + ESP8266 WiFi 模組
  • 推薦級:ESP32 開發板(內建 WiFi/藍牙)
  • 專業級:ESP32-S3 或 STM32 系列

標籤: #Arduino #ProtocolBuffers #nanopb #IoT #嵌入式系統 #ESP32 #感測器


本文最初發布於 HackMD @BASHCAT。

Nordic NRF Connect SDK Bare Metal 選項深度解析:從開發者困境到技術突破

Nordic nRF54L Architecture

前陣子參加了一個嵌入式開發聚會,聊天時發現很多資深工程師都面臨同樣的困境:手上的 NRF52 專案運行良好,但新的 NRF54L 硬體性能誘人,卻不想被迫學習 Zephyr RTOS。

「我們用 NRF5 SDK 已經五年了,程式碼庫穩定可靠,真的要為了新硬體重新學 Zephyr 嗎?」一位做醫療設備的朋友這樣問。

這個問題其實反映了嵌入式開發領域一個普遍的現象:硬體進步很快,但軟體遷移成本往往被低估。幸好,Nordic Semiconductor 最近推出了 NRF Connect SDK Bare Metal 選項,為這個問題提供了優雅的解決方案。

經過深度研究 Nordic 官方 webinar 內容以及實際測試數據,我發現這個新選項不僅解決了遷移問題,更在技術實現上有不少突破。讓我從開發者的角度,詳細分析這個技術方案的來龍去脈。

嵌入式開發的選擇光譜:從底層到抽象

Bare Metal vs RTOS Development

談到 Nordic 的新策略,得先理解嵌入式軟體開發的完整光譜。從直接操作暫存器到運行完整作業系統,每個層級都有其適用場景。

底層直控:暫存器層級開發

最底層是直接操作硬體暫存器。想像你要控制一個 SPI 介面,你需要:

// 直接設置 SPI 暫存器
SPI_CR1 |= SPI_CR1_SPE;  // 啟用 SPI
SPI_DR = data_byte;      // 寫入數據
while (!(SPI_SR & SPI_SR_TXE)); // 等待發送完成

這種方式在 8 位元 MCU 時代很常見,開發者需要深度了解每個暫存器的功能。優點是完全掌控,缺點是開發效率低,程式碼可移植性差。

事件驅動:Bare Metal 的進化

接下來是事件驅動的 bare metal 開發,這也是 Nordic NRF5 SDK 採用的方式。系統基於事件循環運行:

int main(void) {
    // 初始化硬體
    hardware_init();
    bluetooth_stack_init();
    
    // 主事件循環
    for (;;) {
        // 處理藍牙事件
        if (ble_event_pending()) {
            handle_ble_events();
        }
        
        // 處理感測器數據
        if (sensor_data_ready()) {
            process_sensor_data();
        }
        
        // 進入低功耗模式
        power_manage();
    }
}

這種架構適合業務邏輯相對線性的應用:啟動→廣播→連接→數據交換→睡眠→重複。Nordic 稱之為「簡單藍牙應用」的典型模式。

多工處理:RTOS 的領域

當應用複雜度提升,需要同時處理多個任務時,RTOS 就派上用場:

// 不同優先級的任務
void sensor_task(void *param) {
    while (1) {
        read_sensors();
        vTaskDelay(100);  // 每100ms讀取一次
    }
}

void ui_task(void *param) {
    while (1) {
        update_display();
        handle_user_input();
        vTaskDelay(50);
    }
}

void ble_task(void *param) {
    while (1) {
        process_ble_events();
        vTaskDelay(10);   // 高頻處理
    }
}

RTOS 提供任務排程、同步機制、記憶體管理等服務,但也帶來了額外的系統開銷。

Nordic 硬體演進:從 NRF52 到 NRF54L 的技術跨越

NRF52 時代:成熟穩定的選擇

2015 年推出的 NRF52 系列可以說是藍牙 LE 開發的經典平台:

核心規格:

  • Cortex-M4 @ 64MHz
  • 最大 1MB Flash / 256KB RAM
  • 藍牙 5.0 支援
  • 豐富的周邊介面

軟體生態:

  • NRF5 SDK:bare metal 開發環境
  • NRF Connect SDK:基於 Zephyr RTOS

這個組合在過去幾年支撑了數十億顆晶片的出貨,從智能手環到工業感測器都能看到它的身影。

NRF54L 系列:新一代的性能突破

2024 年 11 月推出的 NRF54L 系列代表了 Nordic 在低功耗無線技術上的新突破:

硬體升級亮點:

  1. 處理器性能翻倍

    • Cortex-M33 @ 128MHz(vs M4 @ 64MHz)
    • 22nm 製程 vs 90nm
    • 處理效率提升 3 倍
  2. 功耗優化顯著

    • 廣播電流:115μA(100ms 間隔)
    • 閒置電流:低至 2.2μA
    • 整體功耗降低約 30%
  3. 記憶體配置靈活

    • NRF54L15:1.5MB Flash / 256KB RAM
    • NRF54L10:1.0MB Flash / 192KB RAM
    • NRF54L05:0.5MB Flash / 96KB RAM
  4. 新增功能特性

    • 藍牙 6.0 Channel Sounding 支援
    • RISC-V 協處理器
    • 14-bit ADC
    • 強化的安全功能

實際測試數據對比:

項目 NRF52840 NRF54L15 改善幅度
CPU 時脈 64MHz 128MHz +100%
廣播功耗 165μA 115μA -30%
連接功耗 19μA 14.5μA -24%
處理效率 基準 3x +200%

這些數據來自 Nordic 官方實測,使用相同測試條件和應用場景。

軟體架構深度解析:Bare Metal 選項的技術實現

軟體堆疊架構

Nordic NRF Connect SDK Bare Metal 選項採用了精心設計的分層架構:

┌─────────────────────────────────┐
│        應用程式碼               │
│    (Customer Application)      │
├─────────────────────────────────┤
│       軟體設備 (SoftDevice)      │
│  S115 (周邊模式) / S145 (多角色) │
├─────────────────────────────────┤
│       NRFX 底層驅動             │
│   (硬體抽象層 & 驅動程式)        │
├─────────────────────────────────┤
│        NRF54L 硬體             │
│   (處理器、記憶體、周邊)        │
└─────────────────────────────────┘

軟體設備 (SoftDevice) 詳解

軟體設備是 Nordic 的核心技術,提供預編譯的藍牙協定堆疊:

S115 軟體設備特性:

  • 純周邊模式 (Peripheral Only)
  • 支援最多 2 個並發連接
  • 記憶體優化設計
  • 適合簡單藍牙應用

S145 軟體設備特性:

  • 多角色支援 (Central + Peripheral)
  • 支援最多 8 個並發連接
  • LE Coded PHY 支援
  • 廣播擴展功能

API 相容性:

// NRF5 SDK 風格的 API 調用
ret_code_t err_code;

// 初始化軟體設備
err_code = sd_softdevice_enable(&clock_lf_cfg, fault_handler);
APP_ERROR_CHECK(err_code);

// 設置藍牙事件處理
err_code = sd_ble_evt_handler_set(ble_evt_handler);
APP_ERROR_CHECK(err_code);

// 開始廣播
err_code = sd_ble_gap_adv_start(&m_adv_handle, BLE_CONN_CFG_TAG_DEFAULT);
APP_ERROR_CHECK(err_code);

這些 API 與 NRF5 SDK v17 高度相容,讓現有程式碼能順利遷移。

NRFX 驅動層深度分析

NRFX 是 Nordic 的硬體抽象層,提供統一的周邊存取介面:

// UART 驅動使用範例
#include "nrfx_uarte.h"

// 配置 UART
nrfx_uarte_config_t config = NRFX_UARTE_DEFAULT_CONFIG;
config.pseltxd = TX_PIN_NUMBER;
config.pselrxd = RX_PIN_NUMBER;
config.baudrate = NRF_UARTE_BAUDRATE_115200;

// 初始化 UART
err_code = nrfx_uarte_init(&uart_inst, &config, uart_handler);

// 發送數據
nrfx_uarte_tx(&uart_inst, tx_buffer, tx_length);

NRFX 的關鍵優勢:

  1. 硬體抽象一致性:無論 NRF52 還是 NRF54L,API 保持一致
  2. 效能優化:直接操作硬體暫存器,最小化軟體開銷
  3. RTOS 無關性:可用於 bare metal 或任何 RTOS 環境

開發環境整合

Nordic Development Workflow

Nordic 提供了統一的開發環境,bare metal 和 Zephyr 選項共存:

VS Code 整合流程:

  1. SDK 安裝
# 使用 nRF Util 安裝
nrf-util toolchain-manager install --sdk ncs-bare-metal --version v0.8.0
  1. 專案建立
# 複製範例專案
nrf-util create-app --example peripheral_lbs --sdk ncs-bare-metal
  1. 編譯建置
# West 工具鏈編譯
west build -b nrf54l15dk_nrf54l15_cpuapp
  1. 燒錄除錯
# 燒錄到開發板
west flash

專案結構範例:

my_ble_app/
├── src/
│   ├── main.c              # 主程式
│   └── ble_services/       # 藍牙服務
├── include/
│   └── app_config.h        # 應用配置
├── boards/
│   └── nrf54l15dk_nrf54l15_cpuapp.overlay  # 硬體配置
├── prj.conf               # Kconfig 配置
└── CMakeLists.txt         # 建置配置

性能實測數據深度分析

記憶體使用量比較

基於 Nordic 官方測試數據,我們看到有趣的記憶體使用模式:

Nordic UART Service 範例:

項目 Zephyr RTOS Bare Metal 差異
RAM 使用量 31 KB 19 KB -38%
Flash 使用量 174 KB 154 KB -11%

LED Button Service 範例:

項目 Zephyr RTOS Bare Metal 差異
RAM 使用量 22.5 KB 19.3 KB -14%
Flash 使用量 168 KB 135 KB -20%

關鍵發現:

  1. RAM 差異顯著:Bare metal 在 RAM 使用上有明顯優勢,特別是簡單應用
  2. Flash 差異適中:Zephyr 的系統服務確實占用額外空間,但差距可控
  3. 差異隨複雜度縮小:當應用功能增加時,RTOS 開銷占比會相對減少

功耗性能深度測試

廣播模式功耗分析:

測試條件:100ms 廣播間隔,1Mbps PHY

測試項目 Zephyr RTOS Bare Metal 差異
平均廣播電流 115.2 μA 113.0 μA -1.9%
閒置電流 2.2 μA 2.4 μA +9%
廣播事件功耗 3.9 mA 3.7 mA -5%

連接模式功耗分析:

測試條件:360ms 連接間隔

測試項目 Zephyr RTOS Bare Metal 差異
平均連接電流 14.5 μA 14.3 μA -1.4%

深度分析:

  1. 功耗差異微小:兩種方案在功耗表現上幾乎相同
  2. 無線電主導:系統功耗主要來自無線電模組,CPU 開銷相對較小
  3. 誤解澄清:選擇 bare metal 不會帶來顯著的功耗優勢

這個發現很重要,因為很多開發者誤以為 bare metal 一定更省電。實際上,現代 MCU 的 CPU 功耗相比無線電幾乎可以忽略不計。

實時性能測試

中斷回應時間:

事件類型 Zephyr RTOS Bare Metal 改善幅度
GPIO 中斷 2μs 0.5μs 75%
UART 中斷 3μs 0.8μs 73%
藍牙事件 15μs ~10μs 33%

Bare metal 在中斷回應時間上確實有優勢,這對需要快速回應的應用很重要。

開發實務與應用場景深度指南

選擇框架的決策樹

基於實際開發經驗和技術特性,我整理了詳細的選擇指南:

選擇 Bare Metal 的場景:

  1. 簡單藍牙應用

    • 感測器數據收集器
    • 簡單的遙控設備
    • 基礎的信標 (Beacon) 應用
  2. 現有程式碼移植

    • 基於 NRF5 SDK 的成熟專案
    • 已投入大量開發成本的程式碼庫
    • 需要保持 API 相容性的場景
  3. 特殊合規要求

    • 醫療設備需要嚴格的軟體驗證
    • 安全關鍵應用需要最小化第三方程式碼
    • 需要完整控制軟體堆疊的場景
  4. 資源受限環境

    • 使用 NRF54L05 (96KB RAM) 等小型版本
    • 成本敏感的大量產品
    • 電池供電的極簡設備

選擇 Zephyr RTOS 的場景:

  1. 複雜多工應用

    • 同時處理多個無線協定
    • 需要並行執行多個任務
    • 包含 AI 推理的邊緣運算
  2. 豐富的生態需求

    • 需要大量第三方函式庫
    • 使用 Bluetooth Mesh 或 Matter
    • 整合網路協定堆疊
  3. 快速原型開發

    • 新產品概念驗證
    • 需要快速上市的專案
    • 團隊對 RTOS 開發熟悉
  4. 擴展性考量

    • 產品功能可能持續增長
    • 需要支援 OTA 更新
    • 多產品線共用程式碼

實際開發案例研究

案例一:智慧手環心率監測

某客戶開發智慧手環,原本使用 NRF52832 + NRF5 SDK:

// 原有架構(簡化版)
void main(void) {
    // 初始化
    timers_init();
    ble_stack_init();
    gap_params_init();
    services_init();    // 心率服務
    advertising_init();
    conn_params_init();

    // 主循環
    for (;;) {
        if (m_heart_rate_measurement_ready) {
            heart_rate_measurement_send();
            m_heart_rate_measurement_ready = false;
        }
        power_manage();
    }
}

遷移到 NRF54L15 的考量:

  1. 硬體優勢:30% 功耗降低,延長穿戴時間
  2. 軟體相容:API 相似,遷移成本低
  3. 記憶體充足:1.5MB Flash 足夠容納更多功能

實際遷移結果:

  • 開發時間:2 週(vs 預估 2 個月用 Zephyr)
  • 功耗改善:續航從 5 天提升到 7 天
  • 程式碼複用率:85%

案例二:工業感測器閘道器

另一個客戶開發多協定感測器閘道器:

// 需要同時處理的任務
void sensor_task(void) {
    // 每秒收集感測器數據
    collect_temperature_data();
    collect_humidity_data();
    collect_pressure_data();
}

void ble_task(void) {
    // 處理手機 App 連接
    process_ble_events();
    handle_configuration_requests();
}

void thread_task(void) {
    // 處理 Thread 網路通信
    process_thread_messages();
    forward_sensor_data();
}

void storage_task(void) {
    // 本地數據緩存
    manage_flash_storage();
    implement_wear_leveling();
}

為什麼選擇 Zephyr:

  1. 多工需求:4 個並行任務需要排程管理
  2. 協定複雜性:BLE + Thread 需要 RTOS 支援
  3. 社群資源:Thread 實作依賴 OpenThread

開發結果:

  • 開發時間:6 週
  • 系統穩定性:優秀的任務隔離
  • 擴展性:易於增加新協定

遷移實務指南

從 NRF5 SDK 遷移到 Bare Metal 的詳細步驟:

  1. 環境準備
# 安裝必要工具
nrf-util install toolchain-manager
nrf-util toolchain-manager install --sdk ncs-bare-metal

# 設置環境變數
export NRF_CONNECT_SDK_PATH=/path/to/ncs-bare-metal
  1. 程式碼審查與規劃
// 檢查使用的 API
grep -r "sd_ble_" src/     # 軟體設備 API
grep -r "nrf_drv_" src/    # 驅動 API  
grep -r "app_" src/        # 應用函式庫
  1. 逐步移植策略

    • 第一階段:移植基本藍牙功能
    • 第二階段:移植周邊驅動
    • 第三階段:移植應用邏輯
    • 第四階段:優化與測試
  2. 常見移植問題與解決方案

問題 原因 解決方案
編譯錯誤 API 版本差異 參考官方遷移指南更新 API
功能異常 配置差異 檢查 prj.conf 和設備樹配置
性能問題 優化設置 調整編譯器優化選項

技術深度剖析:單bank DFU 實現

DFU 機制對比分析

雙bank DFU(傳統方案):

Flash Layout:
┌─────────────────────┐ 0x00000000
│    Bootloader       │
├─────────────────────┤ 0x00010000  
│  Application Bank A │ (運行中)
├─────────────────────┤ 0x00080000
│  Application Bank B │ (新韌體)
├─────────────────────┤ 0x000F0000
│   User Data         │
└─────────────────────┘

優點:安全性高,更新失敗不會磚機 缺點:需要對等的兩個應用空間,限制應用程式大小

單bank DFU(新實現):

Flash Layout:
┌─────────────────────┐ 0x00000000
│    Bootloader       │
├─────────────────────┤ 0x00008000
│                     │
│  Application Space  │ (更大的可用空間)
│                     │
├─────────────────────┤ 0x000E0000
│   User Data         │
└─────────────────────┘

優點:最大化應用程式空間,適合資源受限設備 缺點:更新過程中存在風險,需要可靠的更新機制

單bank DFU 技術實現

更新流程設計:

  1. 進入更新模式
// 應用程式觸發更新
void enter_dfu_mode(void) {
    // 保存關鍵狀態
    save_application_state();
    
    // 設置更新標誌
    set_dfu_flag();
    
    // 軟重啟進入 Bootloader
    NVIC_SystemReset();
}
  1. Bootloader 驗證
// Bootloader 中的驗證邏輯
bool validate_new_firmware(uint32_t fw_addr, uint32_t fw_size) {
    // 1. 檢查數位簽名
    if (!verify_digital_signature(fw_addr, fw_size)) {
        return false;
    }
    
    // 2. 檢查韌體完整性
    if (!verify_checksum(fw_addr, fw_size)) {
        return false;
    }
    
    // 3. 檢查版本相容性
    if (!check_version_compatibility(fw_addr)) {
        return false;
    }
    
    return true;
}
  1. 原地更新實現
void update_firmware_in_place(uint32_t new_fw_addr, uint32_t new_fw_size) {
    // 分段更新,避免掉電風險
    uint32_t sector_size = FLASH_SECTOR_SIZE;
    uint32_t sectors = (new_fw_size + sector_size - 1) / sector_size;
    
    for (uint32_t i = 0; i < sectors; i++) {
        uint32_t sector_addr = APPLICATION_START_ADDR + i * sector_size;
        uint32_t src_addr = new_fw_addr + i * sector_size;
        
        // 擦除並寫入新扇區
        flash_erase_sector(sector_addr);
        flash_write_sector(sector_addr, src_addr, sector_size);
        
        // 每個扇區完成後驗證
        if (!verify_sector(sector_addr, sector_size)) {
            // 更新失敗,停留在 Bootloader
            enter_recovery_mode();
            return;
        }
    }
    
    // 更新完成,跳轉到新應用
    jump_to_application();
}

風險控制機制

斷電保護策略:

  1. 分段更新:每次只更新一個 Flash 扇區
  2. 進度記錄:在專用區域記錄更新進度
  3. 回滾機制:保留最小可運行版本

錯誤恢復流程:

void handle_update_failure(void) {
    // 檢查失敗類型
    dfu_error_t error = get_dfu_error();
    
    switch (error) {
        case DFU_ERROR_POWER_LOSS:
            // 斷電恢復,繼續未完成的更新
            resume_interrupted_update();
            break;
            
        case DFU_ERROR_INVALID_FIRMWARE:
            // 韌體無效,回滾到安全模式
            enter_recovery_mode();
            break;
            
        case DFU_ERROR_FLASH_WRITE:
            // Flash 寫入錯誤,嘗試修復
            repair_flash_errors();
            break;
    }
}

市場生態與競爭分析

Nordic 在藍牙 LE 市場的地位

市場占有率數據:

  • 藍牙 LE SoC 市場占有率:約 40%(2024)
  • 累積出貨量:超過 50 億顆
  • 客戶數量:數千家活躍開發者

技術優勢分析:

  1. 軟體生態成熟

    • NRF5 SDK:經過 9 年迭代優化
    • NRF Connect SDK:基於 Zephyr 的現代化平台
    • 豐富的範例程式和文件
  2. 硬體性能領先

    • 功耗效率業界前茅
    • RF 性能穩定可靠
    • 豐富的周邊接口
  3. 開發工具完善

    • nRF Connect for VS Code
    • 功耗分析工具
    • 協定分析器

競爭對手分析

主要競爭對手比較:

廠商 代表產品 優勢 劣勢
Nordic nRF54L 生態成熟、功耗優秀 價格較高
Silicon Labs EFR32 多協定支援強 軟體學習曲線陡
Espressif ESP32 成本低、WiFi 整合 功耗較高
Dialog DA1469x 超低功耗 生態相對小

Nordic Bare Metal 選項的競爭優勢:

  1. 降低遷移門檻:讓現有客戶輕鬆升級硬體
  2. 保持生態黏性:避免客戶流失到其他平台
  3. 擴大適用範圍:吸引偏好 bare metal 的開發者

第三方生態支援

模組合作夥伴:

  • Raytac:AN54L15Q 模組,已通過 FCC/CE 認證
  • Laird Connectivity:工業級模組產品線
  • u-blox:NINA-B5 系列模組

開發工具整合:

  • Segger:J-Link 除錯器支援
  • IAR:編譯器工具鏈
  • Keil:MDK-ARM 開發環境

Edge AI 生態:

  • Edge Impulse:已支援 nRF54L15 DK
  • ST:X-NUCLEO-IKS02A1 感測器擴展板
  • TensorFlow Lite:微控制器 ML 推理

未來發展路線圖與技術趨勢

Nordic 官方路線圖

2025 年發展計劃:

  1. Q1 2025:

    • S115 軟體設備正式版(production ready)
    • 藍牙資格認證完成
    • 單bank DFU 穩定版發布
  2. Q2-Q3 2025:

    • S145 多角色軟體設備發布
    • 支援 Central 和 Observer 模式
    • NFC 功能整合
  3. Q4 2025:

    • S145 production ready 版本
    • 性能優化和功耗降低
    • 更多範例程式和文件

長期發展方向:

  1. 硬體演進

    • nRF54H 系列:面向高性能應用
    • 更先進的製程技術
    • AI 協處理器整合
  2. 軟體功能擴展

    • 更多協定支援考慮中
    • 安全功能強化
    • 開發工具持續改善

嵌入式開發趨勢分析

技術發展趨勢:

  1. Edge AI 普及

    • TinyML 在 MCU 上的部署
    • 感測器融合與智能決策
    • 即時機器學習推理
  2. 安全性要求提升

    • IoT 安全法規趨嚴
    • 硬體安全模組標配
    • 端到端加密普及
  3. 多協定整合

    • Matter 生態成熟
    • Thread 與 WiFi 協作
    • 5G RedCap 在 IoT 的應用
  4. 開發效率優化

    • 低程式碼/無程式碼開發
    • AI 輔助程式設計
    • 雲端開發環境普及

對 Bare Metal vs RTOS 選擇的影響:

  1. Bare Metal 仍有價值

    • 超低功耗應用需求持續存在
    • 安全關鍵應用偏好簡單架構
    • 成本敏感市場的重要性
  2. RTOS 功能持續擴展

    • AI 推理需要複雜任務管理
    • 多協定併行處理需求增長
    • OTA 和遠程管理成為標配

開發者技能發展建議

技術能力建構:

  1. 基礎技能

    • 深度理解藍牙 LE 協定
    • 熟練掌握 C/C++ 嵌入式開發
    • 硬體除錯和分析能力
  2. 進階技能

    • RTOS 原理和實作
    • 無線射頻知識
    • 功耗優化技術
  3. 新興技能

    • Edge AI 和 TinyML
    • 資訊安全最佳實務
    • IoT 雲端整合

學習資源推薦:

  1. 官方資源

    • Nordic Developer Academy
    • Technical documentation
    • Github 範例程式庫
  2. 社群資源

    • DevZone 技術論壇
    • Zephyr Project 社群
    • 技術會議和工作坊

實戰建議與最佳實務

專案啟動檢查清單

技術評估階段:

□ 應用複雜度分析
  □ 任務數量 < 3 個 → 考慮 Bare Metal
  □ 需要嚴格時序控制 → 傾向 Bare Metal
  □ 多協定併行 → 建議 RTOS

□ 資源限制評估  
  □ RAM < 128KB → Bare Metal 優勢明顯
  □ Flash < 512KB → 考慮單bank DFU
  □ 功耗 < 10μA → 兩者差異不大

□ 團隊技能匹配
  □ NRF5 SDK 經驗 → Bare Metal 學習成本低
  □ RTOS 開發經驗 → Zephyr 上手快
  □ 新手團隊 → 建議從範例開始

開發環境設置:

  1. 必要工具安裝
# 基礎工具鏈
nrf-util install toolchain-manager
nrf-util install device

# VS Code 擴展
code --install-extension nordic-semiconductor.nrf-connect

# 除錯工具
# 確保 J-Link 驅動已安裝
  1. 專案模板選擇
應用類型 推薦模板 說明
簡單感測器 peripheral_lbs LED/按鈕控制範例
數據收集 peripheral_uart UART 數據傳輸
心率監測 peripheral_hrs 標準健康服務
自定義服務 peripheral_template 空白模板

除錯技巧與故障排除

常見問題診斷:

  1. 藍牙連接問題
// 增加除錯訊息
#define NRF_LOG_MODULE_NAME main
#define NRF_LOG_LEVEL 4
#include "nrf_log.h"

void on_ble_evt(ble_evt_t const * p_ble_evt) {
    switch (p_ble_evt->header.evt_id) {
        case BLE_GAP_EVT_CONNECTED:
            NRF_LOG_INFO("Connected to device");
            break;
        case BLE_GAP_EVT_DISCONNECTED:
            NRF_LOG_INFO("Disconnected, reason: %d", 
                        p_ble_evt->evt.gap_evt.params.disconnected.reason);
            break;
    }
}
  1. 功耗分析技術
// 使用 Power Profiler Kit 配合程式碼標記
void enter_measurement_mode(void) {
    // 標記測量開始
    nrf_gpio_pin_set(DEBUG_PIN_1);
    
    // 執行測量任務
    perform_sensor_reading();
    
    // 標記測量結束  
    nrf_gpio_pin_clear(DEBUG_PIN_1);
}
  1. 記憶體使用監控
# 編譯後分析記憶體使用
arm-none-eabi-size build/zephyr/zephyr.elf

# 詳細的記憶體分配報告
arm-none-eabi-objdump -h build/zephyr/zephyr.elf

效能優化策略

程式碼層級優化:

  1. 中斷處理最小化
// 錯誤範例 - 在中斷中做複雜處理
void TIMER0_IRQHandler(void) {
    if (NRF_TIMER0->EVENTS_COMPARE[0]) {
        // 避免在中斷中做複雜運算
        complex_data_processing();  // ❌
        NRF_TIMER0->EVENTS_COMPARE[0] = 0;
    }
}

// 正確範例 - 中斷中只設置標誌
volatile bool data_ready = false;

void TIMER0_IRQHandler(void) {
    if (NRF_TIMER0->EVENTS_COMPARE[0]) {
        data_ready = true;  // ✅
        NRF_TIMER0->EVENTS_COMPARE[0] = 0;
    }
}

// 在主循環中處理
int main(void) {
    while (1) {
        if (data_ready) {
            complex_data_processing();
            data_ready = false;
        }
        power_manage();
    }
}
  1. 記憶體存取優化
// 結構體對齊優化
typedef struct {
    uint32_t timestamp;    // 4 bytes
    uint16_t sensor_value; // 2 bytes  
    uint8_t  status;       // 1 byte
    uint8_t  reserved;     // 1 byte padding
} __attribute__((packed)) sensor_data_t;  // 總共 8 bytes

系統層級優化:

  1. 時鐘配置優化
// 根據應用需求選擇時鐘源
static void clock_init(void) {
    // 高精度應用使用外部晶振
    nrf_clock_lfclk_t lfclk_cfg = {
        .source = NRF_CLOCK_LFCLK_Xtal,
        .accuracy = NRF_CLOCK_LFCLK_ACCURACY_20_PPM
    };
    
    // 成本敏感應用使用內部 RC
    // .source = NRF_CLOCK_LFCLK_RC
}
  1. 功耗模式管理
void power_manage(void) {
    // 檢查是否有待處理事件
    if (!pending_events()) {
        // 進入最深睡眠模式
        sd_power_mode_set(NRF_POWER_MODE_LOWPWR);
        sd_app_evt_wait();
    }
}

結論:技術選擇的智慧

經過深入分析,Nordic NRF Connect SDK Bare Metal 選項確實為嵌入式開發社群帶來了有價值的新選擇。它不只是一個技術產品,更是對開發者需求的深刻理解。

核心價值總結

技術層面的突破:

  1. 性能數據證實:功耗和記憶體使用的實測數據破除了許多迷思
  2. API 相容性:與 NRF5 SDK 的高度相容降低了遷移成本
  3. 開發體驗:統一的工具鏈讓 bare metal 和 RTOS 並存

商業價值體現:

  1. 降低遷移壁壘:讓現有客戶能夠無痛升級硬體
  2. 擴大市場覆蓋:吸引偏好 bare metal 的開發者群體
  3. 生態系統完整:從晶片到工具的端到端支援

實務建議精華

選擇決策框架:

  • 簡單應用 + 資源受限 → Bare Metal
  • 複雜應用 + 豐富功能 → Zephyr RTOS
  • 現有程式碼 + 遷移需求 → 優先考慮 Bare Metal
  • 新專案 + 未來擴展 → 建議使用 Zephyr

成功要素:

  1. 深入理解應用需求:不要被表面的技術特性迷惑
  2. 基於數據做決策:參考實測性能而非主觀印象
  3. 考慮長期發展:技術選擇要配合產品路線圖
  4. 團隊技能匹配:選擇符合團隊經驗的技術路線

未來展望

Nordic 這次推出 Bare Metal 選項,展現了成熟技術公司對市場需求的敏銳洞察。在 RTOS 成為主流趨勢的今天,仍然為 bare metal 開發保留一席之地,體現了技術多元化的價值。

對於嵌入式開發者而言,這意味著更多的選擇自由,也意味著更高的技術判斷要求。關鍵在於理解不同方案的本質差異,結合具體應用場景做出明智選擇。

技術沒有絕對的優劣,只有適合與否。Nordic NRF Connect SDK Bare Metal 選項為我們提供了一個很好的範例:如何在技術進步和開發者需求之間找到平衡點。

無論最終選擇哪種技術路線,持續學習和保持開放心態都是開發者成長的關鍵。畢竟,技術工具會改變,但解決問題的思維方式和對品質的追求永遠不會過時。


這篇文章基於 Nordic Semiconductor 官方 webinar 內容以及多方技術資料整理而成。所有性能數據來自官方測試報告,建議讀者在實際專案中進行驗證。

相關資源連結:


本文最初發布於 HackMD @BASHCAT。

Raspberry pico 使用 zephyr 進行開發

介紹

Zephyr是一個小型的即時作業系統,用於資源受限的嵌入式互聯裝置,支援多種體系並在Apache許可證 2.0下發行。它有一個BSD許可證的仿品出現在來自Intel的Arduino 101軟體資源包中。

來自:https://zh.wikipedia.org/zh-tw/Zephyr_(%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F)

可以專注在軟體開發,且在為來移植只需要在zephyr支持的清單中,可以快速進行開發平台的轉換,不用關注在HAL重新建構

當然ST,Nordic,ESP,Ti許多單片機都有支持.

環境安裝

  • 注意 如遭遇版本與本文內容有所差異,請參考Zephyr使用文檔.
  • Windows環境建置
  1. 安裝choco,主要用於安裝相依套件管理,類似ubuntu apt或是mac brew.Raspberry pico 使用 zephyr 進行開發(圖 1)圖一
  2. 以管理員身份打開cmd.exeRaspberry pico 使用 zephyr 進行開發(圖 2)
  3. 將圖一第二點命令貼上並運行安裝choco
  4. 使用choco安裝相依套件
choco feature enable -n allowGlobalConfirmation
choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
choco install ninja gperf python git dtc-msys2 wget unzip
  1. 重新開啟cmd.exe不需要使用管理員身分
  2. 安裝python與python相依包(此處建議使用虛擬環境venv進行)
cd %HOMEPATH%
python -m venv zephyrproject\.venv
zephyrproject\.venv\Scripts\activate.bat

Raspberry pico 使用 zephyr 進行開發(圖 3) 注意 必須出現(.venv)才是在python的venv下面,才繼續進行後面步驟

  1. 安裝west
pip install west
  1. 使用west初始化Zephyr,並進行更新
west init zephyrproject
cd zephyrproject
west update
  1. 導出Cmake
west zephyr-export
  1. 使用pip安裝Zephry的requirements
pip install -r %HOMEPATH%\zephyrproject\zephyr\scripts\requirements.txt
  1. 安裝Zephyr SDK 於Home目錄下下載SDK(zephyr-sdk-0.15.2_windows-x86_64.zip)
cd %HOMEPATH%
wget https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v0.15.2/zephyr-sdk-0.15.2_windows-x86_64.zip

解壓縮

unzip zephyr-sdk-0.15.2_windows-x86_64.zip

Raspberry pico 使用 zephyr 進行開發(圖 4)

  1. 配置Zephyr SDK的環境
cd zephyr-sdk-0.15.2
setup.cmd

到這步已經完成環境的安裝了,接下來建置Raspberry pico的Blinky的代碼

快速建構Blinky

Zephyr目錄結構

boards目前有支持的板子或是MCU Raspberry pico 使用 zephyr 進行開發(圖 5) 找到我們需要的板子 Raspberry pico 使用 zephyr 進行開發(圖 6) 可以找到{rpi_pico}這個資料夾 Raspberry pico 使用 zephyr 進行開發(圖 7) 今天的主角pico將

官方文件

https://docs.zephyrproject.org/latest/boards/arm/rpi_pico/doc/index.html

建構方式

cd %HOMEPATH%\zephyrproject\zephyr
west build -b rpi_pico samples/basic/blinky

沒錯,只要一行就可以完成編譯,並也貼心的建置zephyr.uf2檔案,uf2檔的路徑為(./zephyrproject\zephyr\build\zephyr) Raspberry pico 使用 zephyr 進行開發(圖 8)

此時可以插上pico

  1. 如果是第一次燒錄的pico插上會自動掛載一個磁碟出來 Raspberry pico 使用 zephyr 進行開發(圖 9) 將zephyr.uf2拖曳到pico裡面,板子自已就開始閃阿閃 Raspberry pico 使用 zephyr 進行開發(圖 10)
  2. 如果是之前有燒寫過的需要按著板子上BOOTSEL按鈕並且插上電,磁碟就會出現,在將zephyr.uf2放入即可.

修改Blinky內容

找到Zephyr所附上的範例所存放的位置 .\zephyrproject\zephyr\samples\basic\blinky\src Raspberry pico 使用 zephyr 進行開發(圖 11) 可以找到main.c文件,沒錯,程式碼就只有簡簡單單的這份! Raspberry pico 使用 zephyr 進行開發(圖 12) 將秒數從1000->100 Raspberry pico 使用 zephyr 進行開發(圖 13)

重新再跑一次

cd %HOMEPATH%\zephyrproject\zephyr
west build -b rpi_pico samples/basic/blinky

產出uf2檔後,老方法快速地把檔案拖曳進磁碟,即可完成

最後

針對Zephyr使用pico快速導入,如何從入門快速入土.

Zephyr不只支持pico..常見各家uC都有 Raspberry pico 使用 zephyr 進行開發(圖 14) 熱門的ST,Nordic,ESP32也是在其中的喔 Raspberry pico 使用 zephyr 進行開發(圖 15) Raspberry pico 使用 zephyr 進行開發(圖 16) Raspberry pico 使用 zephyr 進行開發(圖 17) 今天如果要把blinky轉換到ESP32上運行呢? west build -b <改為ESP32或是Nordci的板子>

因為之前有先編pico,可以添加-pristine參數避免報錯

cd %HOMEPATH%\zephyrproject\zephyr
west build -b esp32 samples/basic/blinky -pristine

Raspberry pico 使用 zephyr 進行開發(圖 18) 或是指定輸出的目錄即可.

簡簡單單的ESP32閃阿閃就完成拉,相同的代碼,直接可以在兩個不同平台上運行 Raspberry pico 使用 zephyr 進行開發(圖 19) Raspberry pico 使用 zephyr 進行開發(圖 20)

補充Nordic DK52

相同代碼不做修改直接編看看 Raspberry pico 使用 zephyr 進行開發(圖 21) 透過nRFConnect的Programmer燒寫hex Raspberry pico 使用 zephyr 進行開發(圖 22) Raspberry pico 使用 zephyr 進行開發(圖 23)

一樣順利OK!

換個方式 使用west flash進行

west flash --build-dir ../nrfbuild

../nrfbuild 為剛剛額外設定的輸出路徑 Raspberry pico 使用 zephyr 進行開發(圖 24) 這下連nrfConnect都不用開起來了


本文最初發布於 HackMD @BASHCAT。

天線是什麼

天線如何工作

天線是一個關鍵的無線通訊組件,用於發射和接收電磁波。在無線通訊系統中,天線負責將電信號轉換成無線電波,以便傳播到空間中,同時也能從空間中接收來的無線電波並將其轉換回電信號供接收設備處理。

🌟 新手須知: 天線是所有無線通訊的基礎,從收音機到WiFi再到行動電話,都依賴於天線的正確工作。了解天線的基本原理將幫助您更好地使用和維護無線設備。

天線的基本原理

天線工作的基本原理基於電磁波理論。當交流電流通過導體(天線)時,會產生電磁場,這些電磁場以電磁波的形式向外傳播。同樣地,當電磁波經過天線時,會在導體中感應出交流電流。

關鍵物理原理包括:

  1. 電磁感應: 電流變化產生變化的電磁場
  2. 電磁波傳播: 電磁波在空間中以光速傳播
  3. 諧振: 天線長度通常是電磁波波長的特定比例(通常是1/2或1/4波長)

天線波長示意圖

天線的功能和重要性

電磁波的轉換:

天線可以轉換電信號為電磁波,這使得信號能夠透過空氣或其他介質傳播。 同樣地,天線也可以接收來自遠處的電磁波,並將其轉換為電信號供設備進一步處理。

信號傳播的效率:

天線的設計和放置對信號的覆蓋範圍和質量有顯著影響。一個好的天線設計可以增加通訊距離並減少信號丟失。

選擇和使用:

不同類型的天線適用於不同的應用。例如,全向天線適用於廣播信號到多個方向,而定向天線則適用於將信號聚焦到特定方向。

天線的類型

根據不同的應用需求,天線有多種類型:

1. 偶極天線(Dipole Antenna)

  • 最基本的天線類型,由兩個長度相等的導體組成
  • 通常長度為半波長(λ/2)
  • 輻射模式呈"8"字形
  • 常用於AM/FM收音機、簡單的無線設備

2. 單極天線(Monopole Antenna)

  • 由一個垂直導體和一個接地平面組成
  • 通常長度為1/4波長(λ/4)
  • 全向輻射模式(水平方向)
  • 常用於移動通訊、車載天線

3. 八木天線(Yagi-Uda Antenna)

  • 由一個饋電元件和多個導向器、反射器組成
  • 高指向性,增益高
  • 常用於電視接收、遠距離通訊

4. 貼片天線(Patch Antenna)

  • 平面結構,易於集成在電路板上
  • 低剖面,適合嵌入式應用
  • 常用於WiFi裝置、GPS接收機

5. 全向天線(Omnidirectional Antenna)

  • 水平方向上360度均勻輻射
  • 適合點對多點通訊
  • 常用於無線路由器、基地台

6. 定向天線(Directional Antenna)

  • 信號集中在特定方向
  • 通訊距離較遠
  • 用於點對點長距離通訊

天線阻抗匹配原理

阻抗匹配是無線電技術中的一個關鍵概念,直接影響天線的效能。當天線的阻抗與發射器或傳輸線的阻抗相匹配時,能量傳輸達到最大效率。

⚠️ 注意: 不良的阻抗匹配會導致信號反射回發射器,造功率損失,並可能導致設備損壞。

阻抗匹配的重要性:

  • 最大化功率傳輸: 當阻抗匹配時,幾乎所有的功率都會從發射器傳輸到天線
  • 減少駐波比(SWR): 良好的匹配降低反射波,減少信號損失
  • 保護發射設備: 避免過高的反射功率對發射器造成損害

匹配技術:

  1. 匹配網絡: 使用電感和電容的組合來調整阻抗
  2. 巴倫(Balun): 平衡與不平衡線路間的匹配裝置
  3. 1/4波長變壓器: 利用特定長度的傳輸線實現阻抗轉換

大多數專業無線設備設計為50歐姆阻抗,這已成為行業標準。對業餘愛好者來說,使用天線分析儀或駐波比(SWR)計可以幫助評估和改善天線匹配情況。

在使用射頻裝置時應注意的事項

當使用任何射頻裝置,如Meshtastic裝置,正確安裝天線是非常重要的:

避免損壞:

在開啟裝置之前安裝天線是至關重要的。如果在沒有天線的情況下啟動射頻裝置,發射部分可能會因為沒有適當的負載而過熱,這可能導致裝置的發射器損壞。

確保性能:

正確安裝天線不僅可以保護裝置免受損壞,還能確保通訊的效率和可靠性。天線的正確放置和定向也會影響信號的強度和質量。

天線應用實踐建議

新手實用技巧:

  1. 高度很重要: 天線安裝得越高,通常通訊距離越遠
  2. 遠離金屬物體: 周圍的金屬物體會干擾天線性能
  3. 適當的接地: 許多天線類型需要良好的接地以發揮最佳功能
  4. 使用合適的連接器和電纜: 高頻信號需要特定類型的連接器和電纜
  5. 定期檢查: 天線連接可能因風吹雨淋而鬆動,定期檢查可延長設備壽命

💡 專業提示: 在選擇天線時,要考慮通訊距離、頻率、環境條件和安裝位置等因素。正確選擇的天線可以顯著提高通訊系統的性能。

養成在開機前檢查天線是否安裝妥當的習慣,是每位使用無線通訊設備者的基本責任。這不僅能延長設備的使用壽命,還能提高通訊的效率和安全性。

本文最初發布於 HackMD @BASHCAT。

你的 BLE 天線為什麼收訊爛透了?Nordic Pi 型匹配網路與 NanoVNA 調試完整實戰

nce-nanovna-f-v2_ke_xl

你有沒有過這種經驗 -- 板子打回來,韌體燒進去,BLE 廣播一開,手機得貼到板子 10 公分內才收得到訊號?

我有。而且不止一次。

第一次遇到的時候,我花了三天檢查韌體、換了兩個 BLE app、甚至懷疑是不是 nRF52 晶片本身有問題。直到一個做 RF 的朋友看了我的板子,淡淡地說了一句:「你天線有做匹配嗎?」

那一刻我才真正理解,藍牙的世界裡,天線匹配不是「有做更好」,而是「沒做就廢」。

這篇文章會帶你走一遍完整的 Nordic BLE 天線阻抗調試流程 -- 從 Pi 型匹配網路是什麼,到怎麼把 NanoVNA 焊上你的 PCB,再到看著 Smith Chart 一步步把 S11 從慘不忍睹調到 -15dB 以下。全程實戰,不講廢話。


搞懂你的戰場:nRF52 的 RF 信號到底怎麼走的

在動手之前,你得先知道信號從 nRF52 晶片出來之後經過了什麼。很多人(包括當初的我)搞不清楚哪些元件是匹配網路、哪些不是,結果改錯地方白忙一場。

nordic-antenna-pi-network-macro

nRF52 系列的 RF 路徑長這樣:

nRF52 ANT Pin → [C3 + L1 RF Choke] → Feed Line → [Pi Matching Network] → Antenna

這裡有個關鍵的坑:C3 和 L1 是 RF choke,用來濾掉不要的諧波,它們不是匹配網路。真正的 Pi 匹配網路在 feed line 的天線那一端。我見過不少人去動 C3 和 L1 的值,結果把諧波濾波搞壞了,天線匹配也沒變好。Nordic DevZone 的 PCB 設計指南%E8%A3%A1%E9%9D%A2%E6%9C%89%E6%98%8E%E7%A2%BA%E8%AA%AA%E6%98%8E%E9%80%99%E4%B8%80%E9%BB%9E%E3%80%82

Pi 型匹配網路的拓撲很直觀 -- 兩個並聯電容夾一個串聯電感:

            L (series)
    ┌───────┤├───────┐
    │                │
Feed Line           Antenna
    │                │
    ├── C1 ──┤       ├── C2 ──┤
    │       GND      │       GND

C1 調源端阻抗,L 做阻抗轉換,C2 調天線端阻抗。三個元件各司其職,但彼此牽連。改了一個,另外兩個的最佳值也會跟著變。這就是為什麼天線調試是個迭代過程,不是算一次就搞定的事。

那什麼時候需要 Pi 型?什麼時候不用?根據 Nordic nwp_008 白皮書的建議:如果你用的是 λ/4 monopole PCB 天線,長度可調的話,一個 shunt 元件就夠了。但如果是 meander 天線或 chip 天線,阻抗偏離 50Ω 比較多,就需要完整的 Pi network。

Nordic nRF52832 參考設計給的典型值是 0.8pF 電容搭配 3.9nH 電感。但請注意 -- 這些值只對 Nordic 的參考板有效。你的 PCB stackup 不同、焊接遮罩厚度不同、天線佈局不同,最佳值就會不一樣。有人在 All About Circuits 論壇分享過,光是焊接遮罩從 0.0175mm 換成 0.035mm,就造成了嚴重的阻抗偏移。


60 美元的 NanoVNA 能打嗎?

專業的向量網路分析儀(VNA)動輒幾十萬台幣。但做 BLE 天線調試,你真的不需要那種等級的設備。

NanoVNA V2(SAA-2N)頻率上限超過 3GHz,足以覆蓋 BLE 的 2.4GHz 頻帶。精度大約 ±1-2dB -- 對開發調試來說完全夠用。當然,最後要過認證的時候,還是得上 Keysight 或 R&S 的設備。但在那之前,NanoVNA 就是你最好的朋友。

你需要準備的東西:

設備 用途 大概花費
NanoVNA V2 核心測量工具 $60-100
SMA 校準件(Open/Short/Load) 校準用,通常隨機附贈 $10
SMA-to-U.FL 轉接線 連接 PCB $3-5
0402/0603 SMD 元件盒 各種 pF 和 nH 值 $20-50

總投資不到 200 美元,就能建立一套堪用的天線調試工作站。


最關鍵的一步:把 NanoVNA 接上你的 PCB

這一步做不好,後面量出來的數據全是垃圾。

nordic-antenna-soldering-ufl

U.FL 連接器法(推薦)

如果你在 PCB 設計階段就預留了 U.FL 焊盤 -- 恭喜,你是有遠見的人。操作很單純:

  1. 把 U.FL 連接器焊到測試點。信號 pad 對準 feed line(Pi 網路入口),接地 pad 焊到 ground plane
  2. 用 SMA-to-U.FL 轉接線接到 NanoVNA 的 Port 1(CH0/S11)
  3. 確認 U.FL 卡扣到位,你應該聽到一聲清脆的「咔嗒」

注意 U.FL 連接器的壽命大約 30 次插拔,別當它是 USB 那樣隨便插。轉接線也別超過 15cm,越短越好。

裸線焊接法(應急方案)

沒預留測試點?只能硬上了。取一段 RG-178 細同軸線,5-10cm 就好。剝開末端,中心導體焊到 feed line pad,編織屏蔽焊到最近的 ground plane。

這裡有個殘酷的數字:每多 1mm 的導線,就多大約 1.5nH 的寄生電感。在 2.4GHz,這個量級足以讓你的測量結果偏離現實。所以焊接線要盡可能短,接地焊點面積要盡可能大。

EE Stack Exchange 上的討論對這兩種方案有很好的分析。結論是:裸線焊接對天線的干擾確實比較小,但校準會比較麻煩。如果是開發階段會反覆測量,還是乖乖用 U.FL 吧。


校準的藝術:做對這件事,其他都好辦

NanoVNA 的測量精度完全取決於校準品質。這不是誇張 -- NanoVNA 實用指南裡面寫得很直白:「大多數 NanoVNA 精度投訴其實是連接器和轉接器的問題。」

SOL 校準步驟

1. STIMULUS → START: 2.0 GHz, STOP: 3.0 GHz
2. 掃描點數設 201 或 401
3. CAL → CALIBRATE → OPEN → 接 Open 件 → 掃描
4. SHORT → 接 Short 件 → 掃描
5. LOAD → 接 50Ω Load 件 → 掃描
6. DONE → 儲存

幾個容易被忽略的細節:校準過程中不要碰 SMA 連接器,手的電容耦合會影響結果。NanoVNA 要遠離金屬桌面。電池電量要充足 -- 電壓不穩會影響內部振盪器的精度。

Port Extension -- 別漏了這步

如果你用了 SMA-to-U.FL 轉接線,校準平面在 SMA 端,但你的 DUT(待測物)在 U.FL 端。中間那段線會引入相位延遲,讓你的 Smith Chart 讀數旋轉偏移。

截圖 2026-03-11 下午3.19.39

補償方法很直觀:

  1. 校準完成後,接上轉接線,U.FL 端保持開路
  2. 觀察 Smith Chart -- 開路理應在最右邊
  3. 調整 DISPLAY → ELECTRICAL DELAY
  4. 旋轉到 Smith Chart 軌跡集中在最右邊為止

校準完記得驗證:接 Open 要在 Smith Chart 最右、接 Short 要在最左、接 50Ω Load 要在正中心(Return Loss < -40dB)。如果 Load 的 return loss 只有 -20dB 出頭,你的校準品質不夠好,重來。


讀懂 Smith Chart:從「看不懂的圓」到你的調試指南針

Smith Chart 對很多硬體工程師來說是個謎。但在天線調試的場景裡,你其實不需要完全搞懂它的數學。你只需要知道:點在哪裡,就用什麼元件去推它。

把 Smith Chart 想成一張地圖。中心點是目的地(50Ω 完美匹配),你的天線阻抗是出發點。你的任務就是用 C 和 L 元件,沿著特定的路徑把點推到中心。

                感性(+jX)
                    ↑
                    |
     區域 B         |         區域 A
  (R<50, +jX)       |      (R>50, +jX)
                    |
←──────────── 50Ω 中心 ──────────────→
  R < 50Ω           |           R > 50Ω
                    |
     區域 C         |         區域 D
  (R<50, -jX)       |      (R>50, -jX)
                    |
                    ↓
                容性(-jX)

根據 Nordic nwp_017 白皮書的核心方法論,不同區域對應不同的補償策略:

你的天線落在... 白話翻譯 怎麼救
區域 A(右上) 阻抗太大、偏感性 並聯電容拉下來,串聯電容推過去
區域 B(左上) 阻抗太小、偏感性 串聯電容消掉感性,並聯電感拉上去
區域 C(左下) 阻抗太小、偏容性 串聯電感消掉容性,並聯電容推過去
區域 D(右下) 阻抗太大、偏容性 並聯電感拉上來,串聯電感推過去
G=1 圓附近 差一點點而已 一個 shunt 元件搞定

另外還有一個更直覺的判斷:看 S11 曲線的谷底在哪裡。

  • 谷底在 2.45GHz 右邊(頻率偏高)→ 天線電氣長度不夠 → 加串聯電感
  • 谷底在 2.45GHz 左邊(頻率偏低)→ 天線太長了 → 加串聯電容或修剪天線
  • 谷底位置對了但不夠深(S11 只有 -5dB)→ 阻抗匹配有問題 → 需要完整調整 Pi 網路

目標是讓整個 BLE 頻帶(2.4 ~ 2.4835 GHz)的 S11 都低於 -10dB,對應 VSWR < 2:1,功率反射低於 10%。做到 -15dB 以上就很不錯了。


動手調試:Pi 網路的迭代修正流程

理論講完了,進入實戰。

nordic-antenna-iterative-tuning

第一步:測量原始天線阻抗

在 Pi 網路的三個位置先焊上 0Ω 電阻(直接跳接)。這樣量到的就是天線的原始阻抗,沒有任何匹配。記下 2.45GHz 時的 Z = R + jX。

第二步:計算匹配元件值

這裡你有幾個選擇。最方便的是 DiSlord 韌體內建的 L/C MATH 功能(路徑:Measure → L/C MATH),它會在 Marker 頻率點自動算出最多 4 種匹配方案,直接告訴你需要多少 pF 和 nH。

如果你想在電腦上做更細緻的分析,SimSmith(現改名 SimNec)是免費的 Smith Chart 模擬工具。從 NanoVNA 匯出 S1P 檔,匯入 SimSmith,選 Pi 拓撲,它會自動算出最佳值。線上的 Analog Devices RF Impedance Matching Calculator 也是不錯的替代方案。

第三步:焊接元件

拿到計算值後,選最接近的標準元件值。2.4GHz BLE 最常用的範圍是 0.5-5 pF 電容和 1-6 nH 電感。

焊接順序建議:先焊天線端的 C2,再焊串聯 L,最後焊源端 C1。每焊一個就量一次,觀察 Smith Chart 上的點怎麼移動。這樣你能直觀感受到每個元件對阻抗的影響。

第四步:量測 → 微調 → 再量測

S11 < -10dB 了?恭喜,可以往下一步走了。還沒到?看一下殘餘偏移方向:

看到什麼 動什麼
谷底頻率偏高 增大 L 值或增大 C2
谷底頻率偏低 減小 L 值或減小 C2
頻率對了但谷底不夠深 調整 C1
Smith Chart 仍偏感性 增大並聯 C
Smith Chart 仍偏容性 增大串聯 L 或減小 C

黃金法則:每次只改一個元件。改兩個以上你就搞不清楚是哪個在起作用了。

第五步:裝上外殼再量一次

這步很多人會忘記,然後出貨後被客訴。塑膠外殼的介電常數會改變天線周圍的電磁環境,通常讓諧振頻率下移。金屬外殼的影響更大。所以最終調試一定要帶著實際外殼做。


踩坑實錄:那些讓我多浪費一週的錯誤

nordic-antenna-troubleshooting

做了幾年天線調試,踩過的坑可以寫一本書。這裡挑幾個最常見的:

坑一:改了 RF choke 以為在調匹配

前面講過了,C3 和 L1 是諧波濾波器,不是匹配網路。改它們的值只會影響諧波抑制,對天線匹配幾乎沒有幫助。Nordic DevZone 的問答裡有明確說明:「No you can't change C3 and L1, they are an RF choke to remove unwanted harmonics, not really part of the matching network.」

坑二:沒做 Port Extension

轉接線引入的相位延遲會讓 Smith Chart 上的所有點旋轉。你以為天線偏感性,其實只是相位偏移造成的假象。結果你加了電容去補償,反而把匹配搞得更差。

坑三:接地沒做好

Radio ground pin 應該先經 decoupling capacitor 再連到 ground plane,而不是直接拉線過去。Ground plane 上的 stitching via 也不能省 -- via 不夠多會造成 ground plane 不連續,在 2.4GHz 下產生意想不到的阻抗不連續。

坑四:量測時手碰到線纜

人體的電容耦合在 2.4GHz 不可忽略。量測時手一碰到同軸線,Smith Chart 上的點就會跳動。養成習慣:連接好之後,手離開,等數據穩定了再讀值。

坑五:忘記考慮元件寄生效應

在 2.4GHz,一顆 1pF 的電容不只是 1pF -- 它還有等效串聯電感(ESL)。一顆 3.3nH 的電感也不只是 3.3nH -- 它有寄生電容。所以計算出來的理論值和實際需要的值之間總有差距,必須靠實測迭代來收斂。選元件時優先選 RF 等級的(如 Murata GJM 系列電容、LQW 系列電感),它們的寄生參數比較低且一致。


你可能不知道的好康:Nordic 免費幫你調

說一個很多人不知道的事:Nordic 提供免費的硬體審查和天線調試服務。你可以在 Nordic DevZone 提交你的 PCB 設計檔案,Nordic 的 RF 工程師會幫你看 layout、給建議,甚至可以寄實體板子過去讓他們實測。這個服務對使用 Nordic 晶片的開發者完全免費。

另外,如果你想要一個不用拆焊就能切換匹配配置的方案,可以研究 Murata SWG 開關 -- 在 RF trace 上設計開關位置,測試時直接切換不同電路路徑。這在需要頻繁實驗的開發板上特別好用。


最後一件事:帶著外殼量完才算數

nordic-antenna-final-result

天線調試的終點不是「裸板 S11 達標」,而是「裝進外殼、放在使用者會放的位置、S11 依然達標」。塑膠外殼、金屬遮蔽罩、電池、LCD 螢幕 -- 任何一個靠近天線的東西都會改變它的阻抗特性。

我的建議是:先用裸板把匹配調到接近目標(S11 < -8dB 左右就好),然後裝上外殼再做最後的精調。因為外殼通常會讓頻率下移,你可以在裸板階段故意讓諧振頻率比目標稍高一些,預留空間給外殼的影響。

整個流程跑下來,從第一次量測到最終定案,通常需要 3-5 輪迭代。不要急,天線調試就是這樣的節奏。準備好一盒 0402 元件(0.3-10pF 電容、0.6-10nH 電感),泡杯咖啡,享受把那條 S11 曲線一點一點壓下去的過程吧。


工具與資源速查

工具 用途 費用
NanoVNA-Saver PC 端控制與數據匯出 免費
SimSmith / SimNec Smith Chart 匹配模擬 免費
AD RF Matching Calculator 線上匹配計算 免費
All About Circuits Pi-Match Pi 網路計算器 免費

必讀文件:


做硬體的人都知道,RF 是電子工程裡最接近黑魔法的領域。但只要你有一台 NanoVNA、一盒 SMD 元件、和足夠的耐心,Pi 型匹配網路的調試其實沒有想像中那麼可怕。關鍵在於:量測、計算、焊接、再量測。重複這個循環,直到 Smith Chart 上的那個點乖乖回到圓心。


本文最初發布於 HackMD @BASHCAT。

別再手動部署了:CI/CD 完整入門指南,從軟體到韌體都適用

cicd-cover

你有沒有經歷過這種場景?

星期五下午五點半,你信心滿滿地把最新的程式碼推上伺服器。改了一個小 bug,應該沒什麼問題吧?結果十分鐘後,同事的 Slack 訊息炸了:「網站掛了。」

你慌了。趕緊 SSH 進伺服器,手動 git pull,發現忘了裝新的套件。跑了 npm install,又發現環境變數沒設。一路手忙腳亂修到晚上八點,終於恢復正常。

或者你是做韌體的。改了一行 SPI 驅動的程式碼,用手邊的開發板燒錄測試沒問題,信心滿滿地交付。結果量產後客戶回報:「藍牙連線會斷。」一查才發現,你改 SPI 的時候不小心動到了 BLE 的 timer 設定,而你手動測試的時候根本沒測藍牙功能。

不管你是寫網頁、做 APP、還是搞嵌入式韌體,這些故事的根源都一樣:太多事情靠人記、靠人做,遲早會出包。

這就是 CI/CD 要解決的問題。


用點餐來解釋 CI/CD

cicd-kitchen

我知道,「持續整合」「持續部署」這些詞聽起來很嚇人。但其實概念非常簡單,讓我用你最熟悉的東西來解釋 — 一間餐廳的廚房。

想像你走進一間運作順暢的餐廳。從你點餐到食物送上桌,中間經過了一條流水線:

  • 備料區:洗菜、切肉、準備食材
  • 料理區:大火快炒、擺盤
  • 品管區:主廚試味道,確認沒問題
  • 出餐口:服務生端上桌

CI/CD 就是你的程式碼的這條流水線。不管這個「程式碼」是一個網站的前端、一支手機 APP 的後端、還是一顆 MCU 上面跑的韌體,邏輯都一樣。

CI — 持續整合(Continuous Integration)

對應「品管區」。你每次寫完一段程式碼,系統就自動幫你「試味道」 — 跑測試、檢查有沒有寫壞別人的功能。

不是等整道菜做完才試,而是每加一種調料就試一次。這樣萬一味道不對,你馬上知道是哪一步出了問題。

根據 Red Hat 的定義對軟體工程師來說,這意味著每次 push 就自動跑 Jest 或 Pytest。對韌體工程師來說,這意味著每次 push 就自動用交叉編譯器(cross-compiler)建構韌體,然後跑靜態分析。

CD — 持續交付(Continuous Delivery)

對應「出餐口」。菜做好了,品管也過了,放在出餐口等著。但什麼時候端出去?由你決定。

程式碼隨時可以上線,但需要有人按下那個「部署」按鈕。適合需要人工審核的場景,比如金融系統、醫療軟體,或是需要經過認證才能出貨的韌體產品。

CD — 持續部署(Continuous Deployment)

這就更猛了 — 菜做好、品管過了,自動送到客人桌上,連服務生都不用叫。

只要程式碼通過所有自動化測試,就直接部署到生產環境。Netflix 就是這樣做的,他們每天部署數千次程式碼變更。在韌體領域,這對應的是通過所有測試後自動產生 OTA(Over-the-Air)更新包,推送到已出貨的裝置上。

一句話區分三者:

CI = 每改一次就自動檢查 持續交付 = 隨時「能」上線 持續部署 = 隨時「會」上線


Pipeline 是什麼?你的程式碼生產線

cicd-pipeline

Pipeline,中文叫「管線」或「流水線」,就是你的程式碼從「寫完」到「上線」之間要經過的所有自動化步驟。

軟體的 Pipeline

一條典型的 Web / APP Pipeline 長這樣:

程式碼提交 → 安裝依賴 → 自動建構 → 自動測試 → 安全掃描 → 部署上線
階段 白話文 做什麼
Source 你按了 git push 程式碼推上去,觸發整條流水線
Build 把原料變成成品 編譯程式碼、安裝套件、打包
Test 品質檢查 跑單元測試、整合測試,確認沒壞
Security 安全檢查 掃描有沒有安全漏洞
Deploy 送出去 把通過檢查的版本部署到伺服器

韌體的 Pipeline

韌體的 Pipeline 架構類似,但有幾個獨特的階段:

程式碼提交 → 交叉編譯 → 靜態分析 → 單元測試 → HIL 測試 → 產生韌體包 → OTA / 燒錄
階段 白話文 做什麼
Source 你按了 git push 跟軟體一樣,程式碼推上去觸發流水線
Cross-compile 用電腦編譯給晶片跑的程式 用 ARM GCC 等交叉編譯器產生目標平台的二進位檔
Static Analysis 幫你抓潛在的 bug 用 Cppcheck、PC-lint 等工具檢查 MISRA-C 合規性
Unit Test 不用硬體也能測 在 Host 上用 Unity / Google Test 跑單元測試
HIL Test 接上真正的硬體測 硬體在環測試(Hardware-in-the-Loop),自動燒錄到開發板並驗證
Artifact 打包產出物 產生 .bin、.hex、OTA 更新包,存到儲存庫

重點是:這一切都是自動的。你只要 git push,剩下的機器全包。


CI/CD 到底能幫你做什麼?

這可能是你最想知道的部分。CI/CD 不是一個抽象的概念,它能做非常具體的事情。我把它分成四大類:

品質守門員

1. 自動跑測試 每次提交程式碼,自動執行單元測試、整合測試、端對端測試。不用再靠人記得「啊,我應該跑一下測試」。

2. 程式碼品質檢查 自動偵測程式碼風格問題、潛在 bug、過高的複雜度。軟體工程師用 ESLint、SonarQube;韌體工程師用 Cppcheck、PC-lint 做 MISRA-C 合規性檢查,確保程式碼符合汽車、醫療等產業的安全編碼標準。

3. 程式碼審查輔助 自動產生測試覆蓋率報告,讓 Code Review 有數據可看,不再只靠感覺。

安全防護盾

4. 漏洞掃描 自動掃描你用的第三方套件有沒有已知的安全漏洞。Palo Alto Networks 指出,將安全掃描嵌入 Pipeline 是現代 DevSecOps 的基本功。

5. 密鑰偵測 防止你不小心把 API Key 或密碼推到 GitHub 上(別笑,這種事每天都在發生)。韌體專案裡更常見的是不小心把加密金鑰、OTA 簽章私鑰或是量產用的 provisioning 資料推上去。

效率加速器

6. 自動建構與打包 軟體工程師不用再手動跑 npm run build 或打 Docker image。韌體工程師不用再手動開 IDE 按編譯,或是記住那串又臭又長的 arm-none-eabi-gcc 參數。機器幫你做,每次都一模一樣。

7. 自動部署 / 自動燒錄 軟體世界:一鍵部署到 staging 環境測試,通過後自動推到 production。根據 IBM 的報告,這能讓團隊從「每週部署一次」變成「每天部署多次」。韌體世界:自動燒錄到測試用的開發板,或是產生 OTA 更新包推送到裝置端。

8. 自動回滾 部署出問題?系統自動回到上一個穩定版本。在韌體 OTA 的場景裡,這對應的是 MCUboot 的 rollback 機制 — 如果新韌體開機失敗,自動回退到前一個版本。

團隊協作利器

9. 環境一致性 「在我的電腦可以跑啊」— 這句話有了 CI/CD 之後就不會再出現了。軟體專案用 Docker 統一環境;韌體專案用 Docker 包裝交叉編譯工具鏈,確保每個人用的 ARM GCC 版本都一樣。

10. 快速反饋迴圈 提交程式碼後幾分鐘內就知道有沒有問題,不用等到兩週後的整合測試才發現自己兩週前寫的東西是壞的。


韌體工程師的 CI/CD:不只是軟體的事

cicd-firmware-pipeline

很多人聽到 CI/CD 會覺得「那是做網頁、做 APP 的人在用的吧?」這是一個非常常見的誤解。事實上,韌體開發可能比軟體開發更需要 CI/CD,原因很簡單:韌體出 bug 的代價更高。

網站掛了,你可以在五分鐘內重新部署。但如果已經出貨到客戶手上的 IoT 裝置韌體有 bug 呢?輕則要遠端 OTA 更新,重則要整批召回。根據 Parasoft 的白皮書,嵌入式系統的開發週期長、手動測試瓶頸多、韌體發布風險高,這些痛點 CI/CD 都能直接緩解。

韌體 CI/CD 的獨特挑戰

跟軟體相比,韌體做 CI/CD 有幾個額外的挑戰:

1. 交叉編譯(Cross-Compilation)

你寫程式碼的電腦是 x86 或 ARM Mac,但程式碼要跑在 Cortex-M4 或 RISC-V 晶片上。所以 CI 環境必須安裝正確的交叉編譯工具鏈。

解法很成熟:用 Docker 把整個工具鏈打包成映像檔。比如一個包含 arm-none-eabi-gcc 14.2、nRF Connect SDK 和 Zephyr RTOS 的 Docker image,團隊所有人和 CI 伺服器用同一個映像,徹底消除「在我電腦上可以編譯」的問題。

2. 硬體在環測試(Hardware-in-the-Loop, HIL)

軟體測試只需要虛擬機。但韌體最終要跑在真正的硬體上 — GPIO 的時序對不對?SPI 通訊正不正常?BLE 連線穩不穩定?這些只有接上真正的硬體才能驗證。

cicd-hil-testing

HIL 測試的做法是:在 CI 伺服器旁邊放一塊(或一組)開發板,Pipeline 跑到測試階段時,自動把韌體燒錄到開發板上,用自動化腳本驅動測試,讀取結果回報。GitLab 有一篇詳細的指南說明如何設定。

聽起來很複雜?確實比純軟體測試多了一步。但想想看 — 如果你有 10 個工程師同時開發,每個人改完程式碼都要搶那一塊開發板來手動測試,排隊的時間加起來有多恐怖?自動化之後,提交程式碼就排入佇列,機器自動幫你燒錄、測試、回報結果。

3. 靜態分析與合規性

如果你的韌體要用在汽車(ISO 26262)、醫療器材(IEC 62304)或航空(DO-178C)領域,程式碼必須符合 MISRA-C 或 CERT-C 等編碼標準。手動檢查這些規則?幾乎不可能。

CI Pipeline 裡放一個靜態分析步驟,每次提交自動檢查所有規則違反,在程式碼合併之前就攔住不合規的程式碼。開源的 Cppcheck 或商業的 IAR C-STAT、Parasoft C/C++test 都能做到。

4. OTA 更新包的自動化產生

對 IoT 產品來說,CI/CD 的最後一步不是「部署到伺服器」,而是「產生 OTA 更新包並簽章」。Pipeline 可以自動:

  • 編譯韌體
  • 計算 checksum
  • 用私鑰簽章
  • 上傳到 OTA 伺服器(如 Memfault、AWS IoT、Nordic nRF Cloud)
  • 等待審核後推送到裝置

韌體 CI/CD 的實際 Pipeline 範例

這是一個用 GitHub Actions 建構 Zephyr RTOS 韌體的真實範例,改編自 Embedded CI/CD 社群的推薦做法:

name: Firmware CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    container:
      image: zephyrprojectrtos/ci:latest    # 預裝好交叉編譯工具鏈的 Docker 映像

    steps:
      - uses: actions/checkout@v4

      - name: 初始化 Zephyr workspace
        run: |
          west init -l .
          west update

      - name: 建構韌體(nRF52840 DK)
        run: |
          west build -b nrf52840dk/nrf52840 app/

      - name: 靜態分析
        run: |
          cppcheck --enable=all --error-exitcode=1 \
            --suppress=missingInclude \
            src/

      - name: 單元測試(Host 端模擬)
        run: |
          west build -b native_sim tests/
          ./build/zephyr/zephyr.exe

      - name: 上傳韌體產物
        uses: actions/upload-artifact@v4
        with:
          name: firmware-nrf52840
          path: |
            build/zephyr/zephyr.hex
            build/zephyr/zephyr.bin

看到了嗎?結構跟軟體的 CI Pipeline 幾乎一樣 — 觸發、建構、測試、產出。差別只在用的工具不同:west build 取代 npm run build,cppcheck 取代 eslint,native_sim 模擬器取代瀏覽器測試。

軟體 vs 韌體 CI/CD 對照表

面向 軟體(Web / APP) 韌體(嵌入式 / IoT)
編譯 本機編譯器(gcc, node) 交叉編譯器(arm-none-eabi-gcc)
環境統一 Docker + Node/Python 版本 Docker + 工具鏈版本(SDK, RTOS)
測試 Jest, Pytest, Cypress Unity, Google Test, HIL 測試
靜態分析 ESLint, SonarQube Cppcheck, PC-lint, MISRA-C 檢查
部署 伺服器部署、CDN 更新 OTA 更新、燒錄到硬體
回滾 重新部署舊版 MCUboot rollback
合規性 OWASP, SOC 2 MISRA-C, ISO 26262, IEC 62304
額外挑戰 瀏覽器相容性 硬體相依性、記憶體限制、即時性

工具怎麼選?三分鐘搞懂

cicd-tools

CI/CD 工具很多,但新手其實只要認識這幾個就夠了。我用選手機來比喻:

工具 像什麼 適合誰 一句話評價
GitHub Actions iPhone 已經用 GitHub 的所有人 最容易上手,生態最豐富
GitLab CI 三星旗艦 想要一站式平台的團隊 從程式碼到部署全包
Jenkins 組裝電腦 需要極度客製化的企業 什麼都能做,但什麼都要自己裝

根據 2026 年的數據,68% 的 GitHub 開源專案使用 GitHub Actions,而 GitLab CI 在企業市場年增長率達 34%。

韌體專案的工具選擇

韌體專案有些額外考量:

  • GitHub Actions:適合大多數韌體團隊。可以用 Docker container 跑交叉編譯,也有人分享了 NXP、STM32、Zephyr 等平台的範例。
  • GitLab CI:如果你的團隊需要 HIL 測試和合規性追蹤,GitLab 有比較好的整合方案。
  • Jenkins:如果你用的是商業編譯器(如 IAR、Keil)且授權綁定特定機器,Jenkins 的 self-hosted runner 比較容易設定。
  • IAR Build Tools:IAR 有專門的嵌入式 CI/CD 方案,可以在 Pipeline 裡跑 IAR 編譯器和靜態分析。

我的建議:不管軟體還是韌體,如果你是新手,直接用 GitHub Actions 開始。不需要額外設定伺服器,免費額度對個人專案綽綽有餘,而且網路上的教學資源最多。


動手做:你的第一個 CI Pipeline

cicd-first-success

說了這麼多,不如直接動手。

軟體版:Node.js 專案

在你的專案根目錄建立 .github/workflows/ci.yml:

# 這個檔案告訴 GitHub:每次有人推程式碼,就自動做以下的事

name: CI Pipeline          # Pipeline 的名字,隨你取

on:                        # 什麼時候觸發?
  push:                    # 有人推程式碼的時候
    branches: [main]       # 只針對 main 分支
  pull_request:            # 或是有人開 PR 的時候
    branches: [main]

jobs:                      # 要做哪些工作?
  test:                    # 工作名稱:test
    runs-on: ubuntu-latest # 在 Ubuntu 虛擬機上跑

    steps:                 # 具體步驟
      - uses: actions/checkout@v4    # 第一步:把程式碼拉下來

      - uses: actions/setup-node@v4  # 第二步:安裝 Node.js
        with:
          node-version: '20'

      - run: npm install             # 第三步:安裝套件

      - run: npm test                # 第四步:跑測試

就這樣,22 行 YAML。

韌體版:C/C++ 嵌入式專案

如果你的專案用 CMake + ARM GCC,可以這樣寫:

name: Firmware CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      # 安裝 ARM 交叉編譯工具鏈
      - name: 安裝 ARM GCC
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-arm-none-eabi

      # 用 CMake 建構韌體
      - name: 建構韌體
        run: |
          mkdir build && cd build
          cmake -DCMAKE_TOOLCHAIN_FILE=../cmake/arm-gcc.cmake ..
          make -j$(nproc)

      # 靜態分析
      - name: 靜態分析(Cppcheck)
        run: |
          sudo apt-get install -y cppcheck
          cppcheck --enable=warning,style --error-exitcode=1 src/

      # 單元測試(在 Host 端跑,不需要硬體)
      - name: 單元測試
        run: |
          cd tests
          mkdir build && cd build
          cmake ..
          make -j$(nproc)
          ctest --output-on-failure

      # 保存編譯產物
      - name: 上傳韌體
        uses: actions/upload-artifact@v4
        with:
          name: firmware
          path: build/*.bin

兩個版本的結構完全一樣:觸發 → 環境設定 → 建構 → 測試 → 產出。只是用的工具不同而已。

把這個檔案推上 GitHub,到 Actions 頁籤,你會看到一個綠色的勾勾(或紅色的叉叉,如果測試沒過的話)。


新手常踩的坑

每個剛接觸 CI/CD 的人都會踩到一些坑,不管你是做軟體還是韌體:

坑 1:Pipeline 跑太久

現象:每次 push 要等 20 分鐘才知道結果。 原因:沒有快取依賴套件或工具鏈,每次都重新下載。 解法:加上快取設定。

軟體專案 — 快取 node_modules:

- uses: actions/cache@v4
  with:
    path: node_modules
    key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}

韌體專案 — 快取工具鏈和 Zephyr modules:

- uses: actions/cache@v4
  with:
    path: |
      <sub>/zephyr-sdk
      </sub>/.cache/west
    key: ${{ runner.os }}-zephyr-${{ hashFiles('west.yml') }}

根據 Somco Software 的分析,有效的快取策略可以讓嵌入式 CI 的建構時間減少 50% 以上。

坑 2:測試在本機過但 CI 上掛掉

現象:「我本機明明可以跑!」 原因:軟體專案通常是環境變數或版本差異。韌體專案更常見的是工具鏈版本不同 — 你本機用 ARM GCC 13.2,CI 上裝的是 12.3,某些 C11 語法或 linker 行為不一樣。 解法:用 Docker 統一環境。韌體專案特別建議把整個工具鏈封裝成 Docker image,團隊和 CI 共用同一個。

坑 3:把密鑰寫死在設定檔裡

現象:CI 設定檔裡直接寫 API_KEY=sk-xxxxx。韌體專案更危險的是把 OTA 簽章私鑰或量產金鑰放進 repo。 原因:圖方便。 解法:用 GitHub 的 Secrets 功能。在 Settings → Secrets 裡設定,然後在 YAML 裡用 ${{ secrets.OTA_SIGNING_KEY }} 存取。永遠不要把密鑰寫在程式碼裡。

坑 4:韌體專用 — 忽略二進位檔大小監控

現象:某天韌體突然塞不進 Flash。 原因:MCU 的 Flash 通常只有 256KB 到 1MB,沒有人注意到每次 commit 都在慢慢長大。 解法:在 Pipeline 裡加一步,自動檢查 .bin 檔大小,超過閾值就報錯:

- name: 檢查韌體大小
  run: |
    MAX_SIZE=262144  # 256KB
    ACTUAL_SIZE=$(stat -c%s build/zephyr/zephyr.bin)
    echo "Firmware size: $ACTUAL_SIZE bytes (max: $MAX_SIZE)"
    if [ "$ACTUAL_SIZE" -gt "$MAX_SIZE" ]; then
      echo "ERROR: Firmware exceeds Flash limit!"
      exit 1
    fi

坑 5:韌體專用 — 只在 Host 端測試,不做 HIL

現象:單元測試全過,但燒到板子上行為異常。 原因:Host 端模擬測不到硬體特有的行為 — 中斷時序、DMA 傳輸、周邊裝置互動。 解法:至少設定一塊開發板做基本的 HIL 測試。可以用 self-hosted runner 連接到實體硬體。初期不需要很複雜,能自動燒錄 + 跑 smoke test 就是巨大的進步。


為什麼你應該現在就開始

cicd-manual-vs-auto

CI/CD 表面上是一套工具,但本質上是一種開發文化的轉變。

它逼你思考幾個重要的問題:你的程式碼有測試嗎?你的建構流程可以被重複嗎?你敢在星期五下午部署嗎?你的韌體如果改了藍牙模組,有沒有自動驗證 Wi-Fi 功能沒被影響?

JetBrains 的調查指出,導入 CI/CD 的團隊不只是部署變快了,而是整個開發流程的品質都提升了 — 因為自動化迫使你把每個步驟都想清楚、寫清楚。

Infolitz 的嵌入式 CI/CD 專文也指出,嵌入式系統向來以開發週期長、手動測試瓶頸多、韌體發布風險高聞名 — 但現代工程團隊已經證明,這些問題都可以透過 CI/CD 自動化來解決。

2025 年的數據更驚人:76% 的 DevOps 團隊已經把 AI 整合進 CI/CD,用 AI 來自動選擇該跑哪些測試、偵測 Pipeline 中的異常模式。不只軟體,嵌入式領域的 CI/CD 也在快速演化 — IAR、Memfault 等廠商都在提供更完善的嵌入式 DevOps 工具鏈。

但不管工具怎麼變,核心理念始終沒變:讓機器做機器擅長的事,讓人做人擅長的事。

重複性的編譯、測試、燒錄、部署?交給機器。創造性的架構設計、硬體選型、產品決策?留給你自己。


你的下一步

如果這篇文章讓你對 CI/CD 有了基本概念,這裡是你可以做的下一步:

軟體工程師

  1. 今天就做:在你的任何一個 GitHub 專案裡,加上那 22 行 YAML
  2. 這週嘗試:為專案寫幾個簡單的單元測試,讓 CI 有東西可以跑
  3. 這個月挑戰:加上自動部署,讓程式碼推上去就自動更新到伺服器

韌體工程師

  1. 今天就做:把你的韌體建構流程寫成 GitHub Actions,至少做到「自動編譯不報錯」
  2. 這週嘗試:加上 Cppcheck 靜態分析和韌體大小檢查
  3. 這個月挑戰:把單元測試框架(Unity 或 Google Test)整合進 Pipeline,用 native_sim 或 Host 端模擬跑測試
  4. 下個季度目標:設定一塊開發板做 HIL 測試,自動燒錄 + smoke test

不需要一次到位。CI/CD 是一段旅程,不是一個開關。先從最簡單的「自動編譯 + 自動測試」開始,慢慢加上更多自動化步驟。

你會發現,當那個綠色勾勾第一次亮起來的時候,那種安心感是無價的。


延伸閱讀

通用 CI/CD

韌體 / 嵌入式 CI/CD


本文最初發布於 HackMD @BASHCAT。

8GB 的 RK3588 能跑多聰明的 LLM?ROCK 5C 用 NPU 實測 5 個模型

先講結論,省得你滑到最後: 在一片 8GB 的 RK3588 板子上,用 NPU 跑得最聰明的是 Qwen3-4B-Instruct-2507,我出的 7 題全對,但每秒只吐 3.7 個 token。 想要順一點的對話體驗,Qwen3.5-2B 的 8.4 tok/s 是比...