简介:PDU短信编解码是GSM网络中短信传输的关键协议处理环节,用C语言实现该格式的编解码,适合嵌入式开发者、通信相关程序员以及需要对接短信网关的实践者参考学习。包内共13个文件,压缩后618KB,包含4个头文件与3个C++源文件,分别对应编码、解码、modem交互与线程管理;另有doc、docx、pdf文档详解CMPP3.0长短信方案、消息编码解码的C语言实现以及GSM短消息解码原理,便于对照源码理解。已有652人学习下载。通过这个demo可掌握SCA、DA、MR、DCS、UD等PDU字段含义,理解7位ASCII、8位、UCS-2编码的切换机制;源码与文档结合,可在短信网关、嵌入式短信模块或自定义串口工具中移植扩展,也能加深对短信收发链路底层工作方式的认知。
1. PDU短信编解码demo:串口调试中最实用的C语言参考
做嵌入式开发的人,很多都绕过不了一条短信的坎。最近给一台采集终端接GSM模块,客户要求把告警内容发成中文短信,模块默认的文本模式一遇中文就变成问号,翻协议文档才找到PDU模式这条正路。PDU短信编解码demo(C语言)是一份可以直接编译运行的参考实现,它把SMSC字段处理、7-bit压缩、UCS2中文编解码、长短信UDH拆分这些常见协议逻辑整理成了清晰的C代码。和文本模式相比,PDU模式能承载中文、长短信和特殊字符,代价是字节序、位偏移、长度计算这些细节一步都不能错。这份资源适合做嵌入式终端、串口调试工具、AT指令协议栈的人。新手可以照着代码理解协议结构,熟手可以直接把函数抽出来替换自有实现,不必从零处理那些绕不过去的半字节交换和位缓冲逻辑。
2. PDU报文拆解:SMSC、TPDU与7-bit的存储边界
2.1 从AT指令响应到内存里的字节流
PDU模式的短信在AT指令里是一串连续的十六进制字符,模块通过 AT+CMGL、AT+CMGR、AT+CMGS 这些命令和外部交换数据。解码的第一步不是直接进字段解析,而是先把串口收到的松散文本转成内存里的连续字节。因为AT响应除了PDU本体,还会带记录序号、状态、号码、时间戳这些头部,比如:
+CMGL: 1,"REC READ","10086","24/11/07 10:30:00+8" 0891683108201005F0040D9168311987654321F800009041240711083000...第二行才是真正要处理的PDU字符串。解析时通常先按引号或者空格把头部切掉,再定位到hex串的起始位置,之后才能做转换。
这里有个容易被忽略的事实:PDU报文本身不是二进制传输,而是十六进制字符串传输。GSM模块的AT指令集只接受可打印字符,所以协议数据通过hex编码变成了文本。C代码里一般维护两个缓冲区:一个是收到的hex字符串,另一个是转换后的字节数组。后续所有字段解析都发生在字节数组上,hex字符串只是传输形态。如果直接在字符串上按字符位置解析,很容易把一个字节拆成两个半字节,平白多出一堆边界错误。
2.2 关键字段逐一定位:从SMSC到UDL
把一个完整的PDU报文按字节拆开,大概是下面这个结构:
| 字段 | 长度 | 说明 |
|---|---|---|
| SMSC长度 | 1字节 | 后续SMSC信息的字节数,0表示使用默认短信中心 |
| SMSC类型 | 1字节 | 0x91代表国际格式,0x81代表国内格式 |
| SMSC号码 | 可变 | BCD半字节交换后的号码 |
| TPDU首字节 | 1字节 | bit0-1为MTI,bit6为UDHI,bit2为RP等标志 |
| 地址长度 | 1字节 | 目的号码的十进制位数 |
| 地址类型 | 1字节 | 一般为0x91 |
| 地址内容 | 可变 | 目的号码BCD编码 |
| PID | 1字节 | 协议标识,普通短信为0x00 |
| DCS | 1字节 | 编码方案,0x00为7-bit,0x08为UCS2 |
| SCTS/DCS时间 | 7字节 | 服务时间戳,仅接收短信时存在 |
| UDL | 1字节 | 用户数据长度,7-bit模式是字符数,UCS2模式是字节数 |
| UD | 可变 | 用户数据正文 |
对应用层来说,最需要盯住两个字段:DCS和UDL。DCS决定了后续用哪条解码路径,UDL决定了从哪个位置截断。PDU里还有一个容易看漏的约束:地址长度填的是号码的十进制位数,不是BCD字节数。比如“13912345678”是11位,地址长度字段就是0x0B,而后面的BCD编码加上补F一共占6字节。这个区别在刚接触时几乎必错。
实际操作时,拆字段就是按偏移量往下走。比如从hex字符串转换后的字节数组raw里,SMSC部分用一段简单的指针移动就能跳过:
size_t off = 0; uint8_t smsc_len = raw[off++]; // 0表示使用默认短信中心 if (smsc_len > 0) { off += 1 + smsc_len; // 跳过类型字段和号码本体 } /* 此时 off 指向 TPDU 首字节 */这里smsc_len的单位是字节,但SMSC号码如果出现奇数位会补F凑成整字节,所以直接加smsc_len就能跳过。首字节在off位置,需要单独解出UDHI标志,决定后面是否要处理UDH头。
2.3 为什么用C写:栈上解析、串口生态和移植边界
这套逻辑其实不挑语言,但C版本对嵌入式最友好。一方面GSM模块的串口读写、AT指令状态机本来就是C/C++的世界,Linux下用termios、MCU上用串口中断,代码可以原样复用;另一方面PDU解析依赖大量指针操作和位运算,C的表达方式最直接,不需要额外的运行时库。demo里编解码核心和串口层是完全解耦的,解码函数输入一个char*字符串、输出一个结构体,只要把这层接口留好,上位机工具和MCU固件都能接。
相比Python或脚本方案,C版本的另一个好处是内存模型透明。AT响应缓冲、PDU字节数组、解析结果三个缓冲区都可以在栈上分配,不引入动态内存分配。对资源受限的单片机来说,这一点很关键——demo里按256字节或512字节算缓冲区,最多容纳一条完整的长短信分片,不会因为堆碎片产生玄学问题。如果你是在PC上调试,这代码同样能编过,只是串口初始化那块换成termios或者直接用现成的串口库。
3. 解码链路:从AT串口到可读短信的三个函数
3.1 十六进制字符串转字节缓冲区
解码路径的第一步是写一个可靠的hex转byte函数。很多人在这一步没做长度校验和非法字符检查,模块串口偶尔带回车换行或者AT回显前缀时解析直接崩。常见做法是先剥离控制字符,再调用转换函数:
static int hex_char_val(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'A' && c <= 'F') return c - 'A' + 10; if (c >= 'a' && c <= 'f') return c - 'a' + 10; return -1; } static int pdu_hex2buf(const char *hex, size_t hex_len, uint8_t *out, size_t out_cap) { if (hex_len % 2 != 0) return -1; size_t out_len = hex_len / 2; if (out_cap < out_len) return -1; for (size_t i = 0; i < out_len; i++) { int hi = hex_char_val(hex[i * 2]); int lo = hex_char_val(hex[i * 2 + 1]); if (hi < 0 || lo < 0) return -1; out[i] = (uint8_t)((hi << 4) | lo); } return (int)out_len; }逻辑说明:hex_len必须是偶数,否则说明PDU串本身就不完整。输出缓冲区容量由调用方传入,容量不够直接返回-1,绝不在函数内部重新malloc。每个字节的高4位来自前一个字符,低4位来自后一个字符,转换前先做合法性检查,遇到非法字符立即报错,避免把错误数据带到后续解析。
参数说明:hex_len是有效hex串的长度,而不是strlen整个AT响应;out_cap是输出字节数组的容量。实际项目里AT响应可能带“+CMGL: 1,"REC READ"...”,要先定位到内容字段再调用,这个函数只关心纯hex串部分。
3.2 GSM 7-bit解包:位缓冲循环与容量论证
7-bit模式是PDU解码里最核心也最容易写错的部分。GSM 7-bit字符集每个字符占7位,但存储单位是8位字节,所以字符流和字节流之间存在一个跨字节的位偏移。正确做法是用一个累加位缓冲,每次从输入字节取8位,再按7位粒度往外抠字符:
static int gsm7_decode(const uint8_t *in, size_t in_len, char *out, size_t out_cap) { uint32_t bit_buf = 0; unsigned bit_cnt = 0; size_t o = 0; for (size_t i = 0; i < in_len && o + 1 < out_cap; i++) { bit_buf |= ((uint32_t)in[i]) << bit_cnt; bit_cnt += 8; while (bit_cnt >= 7 && o + 1 < out_cap) { out[o++] = (char)(bit_buf & 0x7f); bit_buf >>= 7; bit_cnt -= 7; } } if (bit_cnt > 0 && o + 1 < out_cap) { out[o++] = (char)(bit_buf & 0x7f); } out[o] = '\0'; return (int)o; }逻辑说明:每次循环把新读入的8位拼到bit_buf的当前高位位置,然后while循环按7位一次一次取出字符。每次取走后bit_buf右移7位,bit_cnt减去7,所以bit_cnt永远保持在下一次拼接前的待处理位数。
容量论证:循环开始前bit_cnt一定小于7,因为每轮while结束都会减到0到6之间;进来后加上8,bit_cnt最大是14,最小是8。while循环最多执行两次,bit_buf用uint32_t承接14位数据完全没有溢出风险。这个论证可以让你放心加大输入长度,bit_buf不会因为积累过多位而翻车。
参数说明:out_cap必须保留至少1字节给结尾的'\0',所以判断条件统一用o + 1 < out_cap。函数返回解码出的字符个数,调用方也可以用这个返回值做后续判断。
最后那一个if是很多人会漏的:7-bit压缩流的末尾经常残留不足7位的bit,这些bit是一个有效字符的低位部分,必须再取一次。漏掉它会让每条短信最后少一个字符,而且很难通过肉眼发现。
3.3 UCS2解码:处理中文短信的另一条分支
当DCS为0x08时,正文是UCS2编码,每个汉字对应一个16位码元,传输字节序为大端。C里可以直接按两个字节拼一个uint16_t:
static int ucs2_decode(const uint8_t *in, size_t in_len, uint16_t *out, size_t out_cap) { if (in_len % 2 != 0) return -1; size_t count = in_len / 2; if (out_cap < count) return -1; for (size_t i = 0; i < count; i++) { out[i] = (uint16_t)((in[i * 2] << 8) | in[i * 2 + 1]); } return (int)count; }逻辑说明:PDU的UCS2按大端存放,所以读取时第一个字节左移8位作为高字节,第二个字节作为低字节。输出是uint16_t数组,每个元素就是Unicode码元。如果后续要用printf输出中文,还需要把uint16_t码元转成UTF-8字节流,这部分demo通常不内置,生产环境用系统的编码转换库,或者在MCU上直接查码表映射到GB2312。
边界说明:PDU标准规定UCS2是大端,但实际调试中遇到过个别模块用小端输出中文的情况。如果发现中文全是乱码且确认DCS没错,可以先用一条已知内容的中文短信做对照,确认模块是否遵循标准字节序。非标实现时有发生,不要盲目信任协议文档。
3.4 解码入口:字段拆分与分派
把前面几个函数串起来的入口函数,职责是拆字段、判DCS、分派到不同的解码路径:
typedef struct { char smsc[24]; char addr[32]; int dcs; int udhi; char time[32]; char text[512]; int text_len; } pdu_msg_t; int pdu_decode(const char *hex, pdu_msg_t *msg) { uint8_t raw[256]; int n = pdu_hex2buf(hex, strlen(hex), raw, sizeof(raw)); if (n < 0) return -1; memset(msg, 0, sizeof(*msg)); /* 跳过SMSC字段,解析首字节、DA、PID、DCS、SCTS、UDL */ /* 按dcs分派到 gsm7_decode 或 ucs2_decode */ /* 如果首字节bit6为1,UD前6字节是UDH,UDL需减去6 */ return 0; }入口里最需要注意的是UDH。当首字节的bit6(UDHI)为1时,用户数据的前6字节是协议头,包含长短信参考号、分片总数、当前序号。如果直接把这6字节送去解码,短信正文里会出现3个不可见字符。正确做法是解析完UDH长度后再把正文区域交给7-bit或UCS2解码函数。
4. 编码链路:从文本到PDU的拼装细节
4.1 号码的BCD编码:补F对齐与半字节交换
编码方向比解码更难写,因为要把人可读的号码转成半字节交换后的BCD。以“13912345678”为例,去掉+号后是11位,奇数位需要末尾补F凑成12位,再两两交换。第一个字节的低4位放第一个数字,高4位放第二个数字:
static int digit_val(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'A' && c <= 'F') return c - 'A' + 10; if (c >= 'a' && c <= 'f') return c - 'a' + 10; return -1; } static int number_to_semi_octet(const char *num, uint8_t *out, int cap) { int len = strlen(num); int total = (len + 1) / 2; // 半字节组的字节数 if (cap < total) return -1; for (int i = 0; i < total; i++) { char c1 = num[i * 2]; char c2 = (i * 2 + 1 < len) ? num[i * 2 + 1] : 'F'; int d1 = digit_val(c1); int d2 = digit_val(c2); if (d1 < 0 || d2 < 0) return -1; out[i] = (uint8_t)((d2 << 4) | d1); // 低4位在前 } return total; }这段代码的关键在最后一行的位组装:d2左移4位作为高半字节,d1直接作为低半字节。这跟日常直觉相反,通常写代码的人会习惯把第一个数字放高4位,结果拼出的号码完全反了。补F逻辑也体现在这里:奇数长度时c2取F,确保每个字节的两个半字节都有有效值。
举例验证:13912345678,“13”这一对字符中1放低4位、3放高4位,得到0x31;“91”变成0x19;“23”变成0x32;“45”变成0x54;“67”变成0x76;“8F”变成0xF8。最终BCD串是“3119325476F8”,和协议文档里的示例一致。
4.2 7-bit压缩编码:位缓冲的另一面
编码是解码的镜像过程,每次读入7位,凑满8位就输出一个字节。代码同样用bit_buf作为累加器:
static int gsm7_encode(const char *text, uint8_t *out, size_t cap) { uint32_t bit_buf = 0; unsigned bit_cnt = 0; size_t o = 0; for (size_t i = 0; text[i] != '\0'; i++) { bit_buf |= ((uint32_t)(text[i] & 0x7f)) << bit_cnt; bit_cnt += 7; while (bit_cnt >= 8) { if (o >= cap) return -1; out[o++] = (uint8_t)(bit_buf & 0xff); bit_buf >>= 8; bit_cnt -= 8; } } if (bit_cnt > 0) { if (o >= cap) return -1; out[o++] = (uint8_t)(bit_buf & 0xff); } return (int)o; }逻辑说明:每读一个文本字符,只取低7位拼入位缓冲。bit_cnt在循环开始前一定小于8,加7后最大14,同样不会溢出uint32_t。while循环每输出一个字节就右移8位,直到剩余位数不足8位,留给下一轮继续拼。
参数说明:输入文本中超过0x7F的字符会被截断,因为GSM 7-bit基本字符集只有128个码位。像€这类扩展字符需要查GSM 3.38扩展表,demo默认不做处理,生产环境可以在上层先替换或丢弃。ASCII字符集里所有字母数字符号都兼容,所以大多数英文短信场景能直接跑。
最后那个if不能省。文本“Hello”长度5,总共35个bit,可以完整输出4个字节,但第4个字节里其实包含了最后一个字符的7位和补足的1位0。如果输入长度不是8的倍数,末尾必须有这个收尾操作,否则压缩流会缺最后一组字节。
4.3 拼装TPDU:长度参数与AT指令的正确姿势
编码完成后,需要把各个字段组装成一条完整的PDU,格式是“SMSC + TPDU”。发送时AT指令如下:
AT+CMGS=<tpdu_byte_len> > <PDU hex string><0x1A>这里的长度参数tpdu_byte_len是TPDU部分的字节数,不包含SMSC字段。但“>”提示符后面发送的PDU字符串是包含SMSC字段的完整hex串。这个区别非常容易搞混,很多人在发送阶段报ERROR,原因就是把长度参数按整条PDU算了一遍。
拼装顺序按照字段表依次写入:SMSC长度、SMSC内容,然后是TPDU首字节、地址字段、PID、DCS、UDL、UD。UDL的计算是编码链路里最容易翻车的点:7-bit模式下UDL填文本的字符个数,UCS2模式下UDL填文本的字节数(也就是字符数乘以2)。举个例子,“Hello”在7-bit模式下UDL是5;如果是两个汉字“测试”,UCS2模式下UDL是4。
组装完成后再做一次hex编码,把字节数组转成可打印的十六进制字符串,然后追加CTRL+Z(0x1A)作为短信结束符。串口层发送完PDU后等待“+CMGS: ”或OK,如果收到ERROR,优先检查长度参数和CTRL+Z是否完整。
5. 避坑指南:PDU编解码中容易翻车的5个细节
5.1 7-bit位序错位导致的整条乱码
现象:英文短信解码后前几个字符正常,后面全乱;或者整条短信每隔几个字符错位一位,看起来像被加密过。
原因:PDU里GSM 7-bit是低bit优先存放,字节内第0位对应第一个字符的最低位。如果按字节逐位顺序解析,或者用MSB-first的方式去读,整个位流的对齐就偏了。这个坑在解码时尤其隐蔽,因为前几个字符可能刚好落在字节边界上,看起来正常,越往后错得越离谱。
解决:用位缓冲循环,统一按“低bit优先”处理。写完后用一条固定文本做自测:发送“Hello”得到PDU,再解码回来,必须严格还原成“Hello”。不要看单个字符对不对,要看整串字符是否完全一致。
5.2 UDL长度算错导致短信截断或多出字符
现象:收到的短信内容被截断,最后几个字符丢失;或者解码后正文末尾多出一两个乱码符号。
原因:UDL的定义在不同编码模式下含义不同。7-bit模式UDL是字符个数,UCS2模式UDL是字节数。如果在UCS2模式下按字符数填了2,实际解码时会少读两字节,正文直接缺字;反过来在7-bit模式下按字节数填,解码时会把压缩流的下一段也读进来,多出垃圾字符。
解决:先判断DCS再算UDL。7-bit分支用字符个数,UCS2分支用字节数。可以在代码里写一个工具函数,专门根据DCS计算UDL,避免在业务代码里临时拍脑袋。
5.3 UCS2字节序:大端小端傻傻分不清
现象:中文短信解出来是一堆繁体乱码或空框,英文数字正常,但所有中文都错。
原因:PDU标准要求UCS2按大端字节序存储,但许多编译环境里的wchar_t是平台相关的,x86下是小端。如果直接把wchar_t的二进制内容拷入发送缓冲区,高低字节全部颠倒,接收端按大端解析自然乱码。
解决:编码时不要依赖wchar_t的内存布局,显式把每个码元拆成高8位和低8位分别写入;解码时用in[i2] << 8 | in[i2+1]重新组合。这套逻辑在大小端平台上行为一致,移植到ARM或x86都不会翻车。
5.4 UDH长度和TP_udhi标志不同步
现象:长短信拆分后,手机在短信列表里显示空白,或者拆成两条互不关联的短消息。
原因:往用户数据里插入了UDH协议头,但TPDU首字节的bit6没有置1;或者UDL延续了单条短信的计算方式,没有把6字节UDH长度加进去。接收端看到UDL和实际数据长度不匹配,就会把UDH头里的字节当正文处理。
解决:插入UDH后,首字节必须做raw[0] |= 0x40。UDL的计算公式是“UDH长度 + 正文字节数/字符数”,长短信场景中UDH固定6字节。拆分包时,每个分片的TP_udhi位都要置1,参考号保持一致,分片序号依次递增。
5.5 AT+CMGS长度参数和CTRL+Z的发送时机
现象:串口工具发送AT+CMGS后模块没有回“>”,或者回了提示符但发完数据一直等不到OK,最后超时返回ERROR。
原因:CMGS后面的长度参数写错,把SMSC部分也算进去了;或者还没等到模块的“>”提示符就急着发PDU字符串,模块把后续内容当成AT命令解析;还有一种情况是数据发完后没发CTRL+Z(0x1A),模块认为短信内容还没结束。
解决:发送流程严格按状态机走。先发AT+CMGS={len},等待模块返回“>”再发PDU,PDU发完后立刻追加0x1A,最后等待“+CMGS: ”或OK。如果超时,先检查长度参数是否只统计TPDU,再确认0x1A是否真的发出去了。串口工具里有的需要显式勾选HEX发送才能打0x1A。
6. 验证手段与进阶思路:造一个PDU回环测试器
6.1 自测循环:编码结果喂给解码器
拿到代码后第一步不是接模块,而是先在本地跑一个回环自测。把编码函数生成的PDU字符串直接丢给解码函数,比对最终输出的文本和原始输入是否完全一致:
int test_pdu_roundtrip(void) { uint8_t pdu[256]; int n = pdu_build_submit("", "13912345678", "Hello", 0x00, pdu, sizeof(pdu)); if (n < 0) return -1; pdu_msg_t msg; if (pdu_decode((char *)pdu, &msg) < 0) return -1; if (strcmp(msg.text, "Hello") != 0) return -1; return 0; }这个测试覆盖了号码BCD编码、7-bit压缩、字段拼装和解码的完整链路。只要回环通过,协议逻辑基本可靠。再换一条中文短信,把DCS改成0x08再跑一遍,验证UCS2路径。建议把这个测试做成固定用例,以后每次改代码都执行一次。
6.2 上真机验证的推荐路径
本地回环通过后,再用真实硬件验证一次。方法很简单:拼一条PDU发送到自己的手机,确认收到内容无误;再用同一张SIM卡给模块发一条带中文的回信,AT+CMGL读出来对比解析结果。真机验证主要确认两件事:模块的SMSC是否需要显式配置,以及模块对UCS2字节序是否完全遵循标准。
进阶用法是把demo改造成一个独立的调试工具链。比如收到AT+CMGL输出后自动提取PDU、解码并打印可读文本;或者反过来,从命令行参数读入号码和正文,直接输出可粘贴的PDU字符串。串口层换成termios之后,这个工具就能在Linux开发机上直接跑,调试GSM模块时比手动查表算PDU高效很多。
做协议解析这类工作,最怕的就是“感觉对了”而不是“验证对了”。从那以后我每次改编解码代码,都强制把回环测试跑一遍再上设备,哪怕只是改了一个位运算的细节。这个习惯帮我挡掉过好几次差点上线才暴露的乱码问题,成本极低,收益却实实在在。希望帮到你。
本文还有配套的精品资源,点击获取