透過 GSM Modem (或支援 AT 指令的手機) 經由串口發送簡訊,適用於無網路環境、低成本、自主可控的簡訊應用。本文涵蓋:串口通訊類庫封裝、AT 指令流程、PDU 編碼/解碼 (支援中文 UCS2)、完整發送流程與錯誤處理。
使用 System.IO.Ports.SerialPort 封裝非同步讀寫:
using System;
using System.IO.Ports;
using System.Text;
using System.Threading.Tasks;
public class GsmModem : IDisposable
{
private readonly SerialPort _port;
private readonly StringBuilder _responseBuffer = new();
private readonly TaskCompletionSource _readTcs = new();
private readonly object _lock = new();
public GsmModem(string portName, int baudRate = 115200)
{
_port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
{
ReadTimeout = 2000,
WriteTimeout = 2000,
NewLine = "\r\n",
Encoding = Encoding.ASCII
};
_port.DataReceived += Port_DataReceived;
}
public async Task OpenAsync()
{
if (!_port.IsOpen) _port.Open();
await TestConnectionAsync();
}
private async Task TestConnectionAsync()
{
var resp = await SendCommandAsync("AT");
if (!resp.Contains("OK")) throw new Exception("Modem 無回應");
}
private void Port_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = _port.ReadExisting();
lock (_lock)
{
_responseBuffer.Append(data);
if (_responseBuffer.ToString().Contains("OK") || _responseBuffer.ToString().Contains("ERROR") || _responseBuffer.ToString().Contains("> "))
{
_readTcs.TrySetResult(_responseBuffer.ToString());
}
}
}
public async Task SendCommandAsync(string command, int timeoutMs = 5000)
{
lock (_lock)
{
_responseBuffer.Clear();
_readTcs = new TaskCompletionSource();
_port.WriteLine(command);
}
var completed = await Task.WhenAny(_readTcs.Task, Task.Delay(timeoutMs));
if (completed == _readTcs.Task)
return _readTcs.Task.Result;
throw new TimeoutException($"指令逾時: {command}");
}
public void Dispose() => _port?.Dispose();
}
public static class PduEncoder
{
// 發送簡訊 PDU 編碼
public static string EncodeSubmit(string sca, string phoneNumber, string message, bool unicode = true)
{
// 1. 簡訊中心號碼 (SCA)
string pdu = EncodeAddress(sca, 0x91); // 國際格式
// 2. PDU Type (SMS-SUBMIT)
pdu += "11"; // TP-MTI=01 (SUBMIT), TP-VPF=00, TP-RD=0, TP-SRR=0, TP-UDHI=0, TP-RP=0
// 3. TP-MR (訊息參考) = 00
pdu += "00";
// 4. 目的地址 (DA)
pdu += EncodeAddress(phoneNumber, 0x91);
// 5. TP-PID (協定識別碼) = 00
pdu += "00";
// 6. TP-DCS (資料編碼方案)
pdu += unicode ? "08" : "00"; // 08=UCS2(16-bit), 00=GSM 7-bit
// 7. TP-VP (有效期) = 00 (相對格式,無有效期)
pdu += "00";
// 8. TP-UDL (用戶資料長度) + TP-UD (用戶資料)
if (unicode)
{
byte[] ud = Encoding.BigEndianUnicode.GetBytes(message); // UCS2 Big Endian
pdu += ud.Length.ToString("X2"); // 長度單位:八位元組
pdu += BitConverter.ToString(ud).Replace("-", "");
}
else
{
// GSM 7-bit 編碼較複雜,此處略過,建議統一用 UCS2
throw new NotSupportedException("建議使用 Unicode 編碼");
}
return pdu;
}
private static string EncodeAddress(string number, byte tonNpi)
{
// 移除 + 號
if (number.StartsWith("+")) number = number.Substring(1);
// 長度 (半位元組數)
int len = number.Length;
string lenHex = len.ToString("X2");
// TON/NPI
string tonHex = tonNpi.ToString("X2");
// 號碼內容:兩兩交換
string numHex = "";
for (int i = 0; i < number.Length; i += 2)
{
char c1 = number[i];
char c2 = (i + 1 < number.Length) ? number[i + 1] : 'F';
numHex += $"{c2}{c1}";
}
return $"{lenHex}{tonHex}{numHex}";
}
}
public class SmsSender
{
private readonly GsmModem _modem;
public SmsSender(GsmModem modem) => _modem = modem;
public async Task SendSmsAsync(string phoneNumber, string message, string sca = null)
{
try
{
// 1. 取得簡訊中心號碼 (若未提供)
if (string.IsNullOrEmpty(sca))
{
var resp = await _modem.SendCommandAsync("AT+CSCA?");
// 解析 +CSCA: "+886932123456",145
var match = System.Text.RegularExpressions.Regex.Match(resp, @"\+CSCA:\s*""([^""]+)""");
if (match.Success) sca = match.Groups[1].Value;
else throw new Exception("無法取得簡訊中心號碼");
}
// 2. 設定 PDU 模式
await _modem.SendCommandAsync("AT+CMGF=0"); // 0=PDU, 1=Text
// 3. 編碼 PDU
string pdu = PduEncoder.EncodeSubmit(sca, phoneNumber, message, unicode: true);
int pduLength = (pdu.Length - 2) / 2; // 扣除 SCA 長度欄位後的八位元組數
// 4. 發送指令
string cmd = $"AT+CMGS={pduLength}";
string resp = await _modem.SendCommandAsync(cmd);
if (!resp.Contains("> ")) throw new Exception("Modem 未進入資料輸入模式");
// 5. 發送 PDU 資料 + Ctrl+Z (0x1A)
_modem._port.Write(pdu + "\x1A"); // 直接寫入 SerialPort
// 6. 等待最終回應
string finalResp = await _modem.SendCommandAsync("", 10000); // 空指令只等回應
return finalResp.Contains("+CMGS:") || finalResp.Contains("OK");
}
catch (Exception ex)
{
Console.WriteLine($"發送失敗: {ex.Message}");
return false;
}
}
}
public class SmsToolForm : Form
{
private GsmModem _modem;
private SmsSender _sender;
private ComboBox _cmbPort;
private TextBox _txtPhone, _txtMsg, _txtLog;
private Button _btnConnect, _btnSend;
public SmsToolForm()
{
InitializeComponent();
LoadPorts();
}
private void InitializeComponent()
{
_cmbPort = new ComboBox { Location = new Point(10, 10), Width = 100 };
_btnConnect = new Button { Text = "連接", Location = new Point(120, 10), Width = 80 };
_btnConnect.Click += BtnConnect_Click;
_txtPhone = new TextBox { Location = new Point(10, 50), Width = 200, PlaceholderText = "接收號碼 (含國碼)" };
_txtMsg = new TextBox { Location = new Point(10, 90), Width = 300, Height = 80, Multiline = true, PlaceholderText = "簡訊內容 (支援中文)" };
_btnSend = new Button { Text = "發送", Location = new Point(10, 180), Width = 80, Enabled = false };
_btnSend.Click += async (s, e) => await SendAsync();
_txtLog = new TextBox { Location = new Point(10, 220), Width = 460, Height = 200, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical };
this.Controls.AddRange(new Control[] { _cmbPort, _btnConnect, _txtPhone, _txtMsg, _btnSend, _txtLog });
this.Text = "GSM Modem 簡訊發送工具";
this.ClientSize = new Size(500, 450);
}
private void LoadPorts() => _cmbPort.Items.AddRange(SerialPort.GetPortNames());
private async void BtnConnect_Click(object sender, EventArgs e)
{
if (_cmbPort.SelectedItem == null) return;
try
{
_modem = new GsmModem(_cmbPort.SelectedItem.ToString());
await _modem.OpenAsync();
_sender = new SmsSender(_modem);
_btnSend.Enabled = true;
Log("連接成功");
}
catch (Exception ex)
{
Log($"連接失敗: {ex.Message}");
}
}
private async Task SendAsync()
{
if (_sender == null) return;
_btnSend.Enabled = false;
Log($"發送中... {_txtPhone.Text}");
bool ok = await _sender.SendSmsAsync(_txtPhone.Text, _txtMsg.Text);
Log(ok ? "發送成功!" : "發送失敗!");
_btnSend.Enabled = true;
}
private void Log(string msg) => _txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {msg}\r\n");
protected override void OnFormClosing(FormClosingEventArgs e)
{
_modem?.Dispose();
base.OnFormClosing(e);
}
}
| 現象 | 可能原因 | 解決 |
|---|---|---|
| AT 無回應 | 纜線、驅動程式、COM 埠錯誤、波特率不符 | 檢查裝置管理員、嘗試 9600/115200、更換原廠纜線 |
| +CMS ERROR: 304 | 簡訊中心號碼錯誤 | AT+CSCA? 確認、手動設定 AT+CSCA="+886932123456" |
| 中文亂碼 | 編碼不一致 | 強制使用 UCS2 (TP-DCS=08),確認 BigEndianUnicode |
| 發送超時 | 訊號弱、SIM 卡未就緒、Modem 狀態異常 | AT+CSQ 查訊號、AT+CPIN? 查 SIM 狀態、重啟 Modem |
本文提供完整的 C# GSM Modem 簡訊發送方案:非同步串口封裝、標準 PDU 編碼 (支援中文 UCS2)、AT 指令流程控制、錯誤處理與 UI 整合。關鍵成功因素:穩定的硬體連接、正確的簡訊中心號碼、統一使用 UCS2 編碼、完善的超時與重試機制。現代專案亦可考慮使用 SerialPortStream (NuGet) 獲得更健壯的串口操作。