GSM短信收发实战:AT指令、PDU编码与UCS2中文乱码排查
2026/9/17 7:16:16 网站建设 项目流程

简介:这是一份面向通信工程专业学生与移动通信初学者的GSM设备短信收发实验报告文档,围绕短消息业务(SMS)的网络结构、信令流程与模块控制方法展开,可用于课程实验预习、报告撰写参考或短消息开发入门。压缩包内仅1个doc文件,约471KB,内容完整收录实验目的、实验原理与操作要点。文档系统梳理了SMS的存储转发与传递确认两大机制、点对点短消息与小区广播业务的区别,以及移动台发送和接收短消息时MSC、VLR、HLR与SMSC之间的信令交互过程,并对比Block、Text、PDU三种收发模式,给出PDU编码字段构成与UCS2中文编码实例,还有通过AT命令控制GSM模块完成短信收发的实现思路。目前已有80人学习下载,适合希望理解短信底层流程并动手实践的读者参考。

1. 为什么 GSM 短信收发实验卡住的往往不是模块

很多人第一次做 GSM 设备短信收发实验,流程都差不多:买一块 SIM800C 或 SIM900A 的板子,插上开通短信功能的 SIM 卡,USB 转 TTL 接到电脑,串口助手敲AT,回一个OK,心里一松;接着AT+CMGF=1AT+CMGS="138xxxxxxxx",敲中文进去,模块回了+CMGS: 12 OK,手机上也确实收到了——但收到的是一串问号或者干脆空内容。真正的坑从这一刻才开始。

短信收发这条链路上,AT 指令只是最外面一层壳,壳里面是 GSM 07.05/07.07 定义的一整套编码规则:GSM 7-bit 默认字符集、UCS2、PDU 报文的字段顺序、长短信的 UDH 头、有效期与数据编码方案 DCS。模块不会报错,它只会照着你给的字节发出去,解不出来就是解不出来。

这套东西适合三类人:做物联网设备告警、远程抄表、短信网关的嵌入式工程师;要写实验报告、把「GSM设备短信收发实验.doc」交上去的学生;以及需要把短信通道接进自己业务系统、又不想被第三方云短信绑死的后端开发。往下走,我把选型、AT 底座、编码、收发实战和排错按顺序拆开。

2. GSM 模块选型与 AT 指令底座怎么搭

2.1 SIM800C、SIM900A、EC20 的差别与串口接线

选型不用纠结参数表有多长,看三个点:你所在位置的网络制式、模块是否带独立串口电平转换、以及短信相关的 AT 指令集是否完整。SIM900A 只吃 2G,很多地区 2G 已经在退网,买之前先确认本地还有没有 2G 信号,这是最容易白干一天的坑。SIM800C 同样是 2G,但价格低、资料多,做实验够用。EC20、EC200 这类 4G Cat.1/Cat.4 模块向下兼容短信,指令集是 3GPP 27.007 那一套,只是开机流程更长、功耗更高。

模块制式短信 AT 兼容性典型串口常见坑
SIM900A2GGSM 07.05 完整3.3V TTL2G 退网后注册不上
SIM800C2G完整,支持 TEXT/PDU3.3V TTL供电电流不足会反复重启
EC20 / EC2004G完整,含长短信3.3V TTL,默认 115200开机到可用要 10~20 秒
MC35 / TC352G 老模块部分只支持 PDU3.3V TTLAT+CMGS长度校验

接线本身只有四根:模块 TXD 接 USB-TTL 的 RXD,模块 RXD 接 USB-TTL 的 TXD,GND 共地,VCC 单独供电。供电这一点要单独强调,模块在发射瞬间电流能冲到 2A,直接用 USB-TTL 模块上的 3.3V 引脚供电,十次里有八次会看到模块无限重启或者AT时有时无。

提示:先用万用表确认模块电源脚在发射瞬间的压降,掉到 3.0V 以下就该换独立电源,别怀疑串口线。

2.2 用 pyserial 打通最小 AT 指令闭环

串口助手只能验证一次,做收发实验必须把交互写成代码。下面这段是最小闭环,能开串口、发指令、拿回显,后面所有章节都建立在它上面。

import serial import time PORT = "COM3" # Linux/macOS 下一般是 /dev/ttyUSB0 或 /dev/ttyACM0 BAUD = 115200 # SIM800C 常见 9600/115200,EC20 默认 115200 PER_CMD_TIMEOUT = 1.0 # 单条指令等待回显的时间上限(秒) def open_port(): ser = serial.Serial(PORT, BAUD, timeout=PER_CMD_TIMEOUT) ser.write(b"AT\r\n") # 先打一次 AT,唤醒处于睡眠态的模块 time.sleep(0.3) return ser def send_at(ser, cmd, wait=1.0): ser.reset_input_buffer() # 清掉上一轮残留回显,避免误判上一指令的结果 ser.write((cmd + "\r\n").encode("ascii")) time.sleep(wait) raw = ser.read(ser.in_waiting or 1) return raw.decode("utf-8", errors="replace")

timeout设成 1.0 秒是为了让read在没有数据时能及时返回而不是永久阻塞;reset_input_buffer()是关键,模块回显是异步的,不清缓冲就会出现「上一条的 OK 被当成这一条的结果」。wait参数按指令耗时给:AT给 0.3,AT+CMGS这种要等网络侧的给 5 到 6 秒。

接上模块后,按下面的顺序打一遍,任何一条不符合预期就先停在它上面:

ATE0 # 关回显,命令行干净,脚本解析也简单 AT+CPIN? # 期望 +CPIN: READY,卡没识别就查卡座和 PIN 锁 AT+CSQ # 期望 +CSQ: 15,0 以上,第一位是信号强度 0~31 AT+CREG? # 期望 +CREG: 0,1(已注册本地网)或 0,5(漫游) AT+CMEE=2 # 打开详细错误码,后面报错会告诉你原因而不是 ERROR AT+CPMS="SM","SM","SM" # 短信读写都走 SIM 卡存储,容量小但兼容性最好

AT+CPIN?返回+CME ERROR: 10表示没插卡,+CME ERROR: 11是需要 PIN 码;AT+CSQ第一位长期低于 10,就别测收发了,先解决天线和位置。AT+CMEE=2是我建议一开始就打开的,同样是发送失败,它会把 500、515 这类具体错误码抛出来,省掉半天猜测。

2.3 TEXT 模式和 PDU 模式:AT+CMGF 到底怎么选

AT+CMGF=1是 TEXT 模式,内容直接写字符串;AT+CMGF=0是 PDU 模式,内容要自己拼十六进制。很多人问该用哪个,结论是:做纯英文实验可以 TEXT,只要涉及中文、长短信、状态报告、自定义有效期,就必须 PDU。

对比项TEXT 模式(CMGF=1)PDU 模式(CMGF=0)
中文支持依赖AT+CSCS设置,模块实现不一致明确走 UCS2,可控
长短信部分模块自动拆分,部分不支持自己控制 UDH,行为一致
状态报告结果字段少,解析困难完整 TPDU,可查每段状态
阅读成本高,需要理解字段偏移
调试难度出错信息模糊可以用在线 PDU 工具逐字段核对

TEXT 模式下最典型的翻车是:AT+CSCS="UCS2"之后,AT+CMGS后面的号码和正文都要写成 UCS2 十六进制,写中文反而更别扭。所以实验里我一般先用 TEXT 模式跑通一条纯英文,确认链路没问题,再切AT+CMGF=0做正式的短信收发实验。

3. 短信编码:GSM 7-bit、UCS2 与 PDU 报文拆解

3.1 为什么发中文必须落到 UCS2

GSM 07.05 里的默认字符集是 7-bit,一共 128 个字符的位置,覆盖拉丁字母、数字、常用符号和少量希腊字符,中文根本不在里面。模块在 7-bit 模式下遇到中文字节,要么截断、要么当成扩展字符表里的东西发出去,接收端自然是一串乱码。

解决路径有两条:DCS 字段填0x08走 UCS2,每个字符 2 字节;或者填0x00走 7-bit,只发英文和数字。国内实验最常见的配置就是AT+CSMP=17,167,0,8,第四个参数 8 就是 UCS2 的 DCS,发送中文不用再做其他设置。判断某条短信用了哪种编码,只需要看 PDU 里的 DCS 字段:00是 7-bit,08是 UCS2,F1之类留给厂商自定义。

7-bit 还有个隐藏细节叫「七位压缩」:140 字节的载荷,如果按 7 位算,能塞进 160 个字符,代价是字节边界不对齐,拼包和拆包都要做位移运算。UCS2 没这个问题,代价是容量直接砍到 70 个字符。这就是为什么一条中文短信超过 70 个字会被拆成多条。

3.2 一条 PDU 报文的字段级拆解

以发送「你好」到+8613800138000为例,完整 PDU 是:

0011000D91683108103800F000 08AA044F60597D

按字段拆开看,每一段的边界和含义如下:

字段示例值长度含义
SCA 长度001 字节服务中心号码长度,填 00 表示用模块里存的服务中心号
SCA(无)0 字节长度字段为 00 时,这一段整体省略
首字节111 字节MTI=01 表示 SUBMIT,TP-VPF=10 表示用相对格式有效期
MR001 字节消息参考号,模块自己维护,实测时不对应任何业务
DA 长度0D1 字节目标号码共 13 位数字
DA 类型911 字节91 表示国际号码,本地号码是 81
DA683108103800F07 字节号码按半字节交换后的结果,奇数位补 F
PID001 字节协议标识,普通短信固定 00
DCS081 字节数据编码方案,08 表示 UCS2
VPAA1 字节相对格式有效期,AA 约等于 4 天
UDL041 字节用户数据长度,UCS2 下按字节算,这里是 4 字节
UD4F60597D4 字节「你好」的 UTF-16BE 编码

号码的「半字节交换」最容易被写错:861380013800这种奇数长度要先补F变成8613800138000F,再两两互换得到683108103800F0。写代码时如果发现发出的短信直接进了对方的垃圾箱,多半是这里的长度字段或者 TYPE-OF-ADDRESS 取值错了。

注意:DA 长度统计的是「数字个数」,不是字节数;+8613800138000是 13 个数字,长度字段就该写0D,写成07会直接被网络侧拒绝。

3.3 用 Python 组装和解析 PDU

先处理字符集转换,再组装 SUBMIT 报文,两段逻辑分开写方便调试。

def text_to_ucs2_hex(text: str) -> str: """中文按 UTF-16BE 编码,转成大写十六进制字符串""" return text.encode("utf-16-be").hex().upper() def ucs2_hex_to_text(hex_str: str) -> str: return bytes.fromhex(hex_str).decode("utf-16-be") def encode_number(number: str): """把号码转成 PDU 里的 (长度, 类型, 交换后数字) 三元组""" digits = number.lstrip("+") if len(digits) % 2: digits += "F" # 奇数位补 F,保证两两成对 swapped = "".join(digits[i + 1] + digits[i] for i in range(0, len(digits), 2)) toa = "91" if number.startswith("+") else "81" return "%02X" % len(number.lstrip("+")), toa, swapped def build_submit_pdu(number: str, text: str) -> str: smsc = "00" # 使用模块内置服务中心号 pdu_type = "11" # MTI=01 SUBMIT,TP-VPF=10 相对有效期,不要状态报告 mr = "00" # 消息参考号,随便给,模块会自己重排 da_len, da_toa, da = encode_number(number) pid = "00" # 普通点对点短信 dcs = "08" # UCS2,中文必须 vp = "AA" # 有效期约 4 天 ud = text_to_ucs2_hex(text) udl = "%02X" % (len(ud) // 2) # UDL 按字节数计 return smsc + pdu_type + mr + da_len + da_toa + da + pid + dcs + vp + udl + ud

三个参数值得单独说:dcs="08"决定后面 UD 必须按 UCS2 处理,换成00就会按 7-bit 解析,中文立刻变成乱码;vp="AA"是相对有效期,范围大致是从 5 分钟到 63 周,测试时可以临时改成10缩短到几小时,避免积压的短信干扰后续实验;pdu_type位 5 置 1(也就是写成31)就会要求网络回状态报告,后面能拿到+CDS上报。

4. 收发实战:+CMGS 交互、+CMTI 中断与长短信拼接

4.1 发送:AT+CMGS 的两次回车与 Ctrl+Z

PDU 模式下发短信不是一条指令搞定,而是一段交互:先AT+CMGS=<长度>,模块回一个>提示符,再发 PDU 十六进制串,最后跟一个 Ctrl+Z(0x1A)表示结束。很多人卡在这里,因为串口助手不会自动帮你补0x1A

def send_sms(ser, number, text): send_at(ser, 'AT+CSCS="UCS2"') # 让后续字符集设置一致,避免解析歧义 send_at(ser, "AT+CMGF=0") # 切到 PDU 模式 send_at(ser, "AT+CSMP=17,167,0,8") # 第四位 8 表示 DCS=UCS2 pdu = build_submit_pdu(number, text) tpdulen = (len(pdu) - 2) // 2 # 减去 SCA 长度字段本身,只报 TPDU 长度 ser.reset_input_buffer() ser.write(("AT+CMGS=%d\r\n" % tpdulen).encode("ascii")) time.sleep(0.5) ser.read(ser.in_waiting) # 吃掉 "> " 提示符 ser.write(pdu.encode("ascii") + b"\x1a") # Ctrl+Z 结束输入并触发发送 time.sleep(6) # 等网络侧回 +CMGS: <mr> 和 OK return ser.read(ser.in_waiting).decode("utf-8", errors="replace")

tpdulen的算法是最容易出错的一处:当 SCA 写00时,整个 PDU 里有一部分是 SCA 自身的长度字段,AT+CMGS只要 TPDU 的字节数,所以要把开头那 2 个十六进制字符减掉再折半。少算一个字节,模块会回+CMS ERROR: 304(长度不匹配);多算一个字节,会卡在>提示符后面等不到结果。

返回内容里+CMGS: 12的 12 是消息参考号,不是短信条数;真正的发送成功以最后那个OK为准。如果等了 6 秒只看到ERROR,先把AT+CMEE=2打开重跑一次,拿到具体错误码再定位。

4.2 接收:AT+CNMI 与 +CMTI 的处理循环

接收侧的关键是让模块主动告诉你「来短信了」,而不是轮询。这靠AT+CNMI控制:AT+CNMI=2,1,0,0,0表示新短信先存进存储,然后通过+CMTI: "SM",<index>上报索引,程序拿到索引再用AT+CMGR去读。这样即使程序在处理上一条,短信也不会丢。

def read_sms(ser, index: int) -> str: raw = send_at(ser, "AT+CMGR=%d" % index, wait=1.0) for line in raw.splitlines(): line = line.strip() # +CMGR 后面那一行才是 PDU,跳过状态行和空行 if line and all(c in "0123456789ABCDEFabcdef" for c in line) and len(line) > 20: return parse_deliver_pdu(line) return "" def watch_incoming(ser, interval=1.0): send_at(ser, "AT+CNMI=2,1,0,0,0") # 新短信存 SIM,用 +CMTI 上报索引 send_at(ser, 'AT+CPMS="SM","SM","SM"') # 读、写、收统一走 SIM buf = "" while True: buf += ser.read(ser.in_waiting or 1).decode("utf-8", errors="replace") for line in buf.splitlines(): if line.startswith("+CMTI:"): idx = int(line.split(",")[-1]) print(read_sms(ser, idx)) send_at(ser, "AT+CMGD=%d" % idx) # 读完立刻删,防止 SIM 写满 buf = "" time.sleep(interval)

AT+CNMI四个参数依次是:新短信处理方式(2 表示存起来再上报)、上报方式(1 表示+CMTI,2 表示直接推+CMT全文)、状态报告上报、小区广播。做实验我建议用2,1,0,0,0,因为 SIM 卡本身容量只有几十条,删得勤一点比调试时丢短信强。AT+CMGD放在读取之后立刻执行,这个顺序不能反。

解析 DELIVER 报文时字段偏移和 SUBMIT 不一样:SCA 后面第一个字节是首字节,接着才是发件人长度、类型和号码,然后跳过 PID、DCS 和 7 字节的时间戳 SCTS,才到 UDL 和 UD。SCTS 是 7 字节的半字节交换 BCD,写解析时别按 6 字节读。

4.3 长短信 UDH 拼接与乱序去重

中文超过 70 个字(UCS2 无 UDH 时)就会被拆成多条,每条载荷里带一个 UDH 头用来标识「这是第几段」。常见两种格式:

UDH 字节含义可用载荷UCS2 每段字数
05 00 03 CC 02 018 位参考号,总 2 段,本段第 1 段134 字节67 个字符
06 08 04 12 34 02 0116 位参考号,总 2 段,本段第 1 段133 字节66 个字符

拼接逻辑就是按「发件人 + 参考号」分组,组内按段序号排序,段齐了再合并:

def merge_parts(parts): """parts: [(sender, ref, total, seq, text), ...]""" groups = {} for sender, ref, total, seq, text in parts: groups.setdefault((sender, ref), {})[seq] = text merged = [] for (sender, ref), seg in groups.items(): total = max(seg) if seg else 0 if len(seg) == total and all(i in seg for i in range(1, total + 1)): merged.append((sender, "".join(seg[i] for i in range(1, total + 1)))) return merged

实际抓包里经常遇到乱序:第 2 段比第 1 段先到,或者只有部分段到达。别用「收到第一条就立即展示」,也别用「等固定秒数」这种写法,前者会显示半句话,后者在弱信号下必然出问题。按段序号补齐再输出,给一个合理的超时(比如 30 秒)丢弃不完整的组,是工业现场最稳的做法。

提示:发送端也要配合,超过 70 字的文本要在代码里主动分段、生成 UDH,别指望所有模块的自动拆分行为一致。

5. 排错与实验记录:掉网、乱码、重复上报怎么定位

5.1 故障对照表:现象、根因、先跑哪条指令

现象常见根因验证指令
AT+CMGS一直不给>未切 PDU 模式或长度算错AT+CMGF?、重算 TPDU 长度
手机收到乱码DCS 与 UD 编码不匹配AT+CSMP?看第四位是否 8
+CMS ERROR: 500号码格式或服务中心号问题AT+CSCA?确认服务中心号
反复+CREG: 0,2搜不到网或 SIM 欠费AT+CSQAT+COPS?
收到重复+CMTI读完之后没删,或 CPMS 存储混用AT+CPMS?、补AT+CMGD
中文显示成问号接收端用了 7-bit 解码检查首字节 TP-UDHI 与 DCS
模块随机重启供电不足万用表测发射瞬间压降

这张表的用法是从上往下排:先确认模式设置,再确认编码,最后才怀疑硬件。顺序反了会浪费大量时间在拆线接线上。

5.2 用 CSQ、CREG、CMEE 做自检与自动恢复

长时间跑收发实验,最怕的是模块悄无声息地掉网,代码还在循环调用AT+CMGS,结果全部堆在错误分支里。加一个轻量自检循环,一分钟一次,成本很低:

def health_check(ser): csq = send_at(ser, "AT+CSQ", wait=0.5) reg = send_at(ser, "AT+CREG?", wait=0.5) if "+CREG: 0,1" not in reg and "+CREG: 0,5" not in reg: send_at(ser, "AT+CFUN=0", wait=1.0) # 关射频 send_at(ser, "AT+CFUN=1", wait=10.0) # 重新开机注册,比反复复位稳 return False try: rssi = int(csq.split("+CSQ:")[1].split(",")[0]) except (IndexError, ValueError): rssi = 0 return rssi >= 10 # 低于 10 就认为链路质量不够,暂停发送任务

AT+CFUN=0/1比直接给模块断电温和得多,能避免插卡状态丢失;AT+CFUN=1之后一定要等够 10 秒,4G 模块注册更慢,等 20 秒更保险。信号强度低于 10 时别发长短信,分段短信在弱信号下丢段概率明显上升,宁可进队列等下一轮。

5.3 把 SMS 实验文档写成可复现的记录

「GSM设备短信收发实验.doc」这类报告最容易写成流水账:接了线、敲了指令、成功了。真正有用的记录要包含四样东西,缺一样别人就复现不了。第一是模块型号、固件版本(AT+CGMR能读)、供电方式;第二是完整指令序列和每条的实际回显,包括失败的那几条;第三是关键 PDU 原文,最好是收和发各一条完整的十六进制串,附上字段对照;第四是 SIM 卡的运营商、卡内服务中心号(AT+CSCA?)和测试时间点。

记录时有个小技巧:把发送成功的 PDU 和接收到的 PDU 并排放在同一张表里,因为 SUBMIT 和 DELIVER 的字段偏移不同,并排看能立刻发现「首字节后面是不是多了 SCTS」这类问题。实验做十次不如把一次的结果记到能照着重跑一遍,这才是这条链路真正值钱的部分。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询