简介:这是一份面向嵌入式与通信开发者的PDU短信编解码C语言实现demo,聚焦GSM网络中SMSC与手机之间二进制短信传输的底层原理,适合想深入理解短信协议、动手实现编解码逻辑的中级开发者。资源包共13个文件、约618KB,以h头文件与cpp源文件为主,辅以doc、docx说明文档和pdf资料,另有obj编译产物,覆盖编码、解码、工具函数与主程序等模块,并附带GSM长短短信解码、CMPP3.0长短信方案及bit7/bit8/UCS2串口编码等延伸内容。已有652人学习下载。通过阅读与调试,读者可掌握文本与PDU格式互转、7位ASCII填充、DCS编码方案解析等关键步骤,并借助样例数据验证代码,为短信网关、短信服务器或嵌入式短信模块开发提供可复用的参考实现与排错思路。
1. 从一条乱码短信说起:这个 C 语言 PDU 编解码 demo 到底能干什么
你有没有遇到过这种场景:设备侧发来一串十六进制字符串,看着像短信内容,但直接按 ASCII 解出来全是乱码,里面还夹着00 0D 91这种看不懂的字节。其实这就是 GSM 协议里的 PDU 格式短信,它把号码、编码方式、时间戳、短信正文全部打包成二进制流,不按协议拆根本读不出来。这个 C 语言 PDU 短信编解码 demo 干的就是这件事——把 PDU 串和可读短信互相转换。它适合做嵌入式通信、物联网设备短信收发、串口调试工具的开发者,尤其是需要在资源受限环境里手写协议解析、不想拖一个庞大库进来的场景。整个项目用纯 C 实现,没有第三方依赖,编译出来就是一个小可执行文件,拿来就能嵌进自己的工程里。
2. 项目结构与技术选型:为什么用纯 C 手写而不是拉库
2.1 目录组织与文件职责
这个 demo 的结构非常扁平,典型的小工具型工程布局。我拿到源码后第一件事就是看目录,因为它决定了你改代码时该动哪个文件。
pdu_demo/ ├── main.c # 命令行入口,读取参数、调用编解码、打印结果 ├── pdu.h # 对外接口声明,编解码函数原型和结构体定义 ├── pdu_encode.c # 编码方向:可读短信 -> PDU 十六进制串 ├── pdu_decode.c # 解码方向:PDU 十六进制串 -> 可读短信 ├── pdu_utils.c # 工具函数:BCD 转换、十六进制字符串处理、GSM 7bit 打包 ├── Makefile # 编译脚本,gcc 一条命令出可执行文件 └── test_data/ # 若干条 PDU 样例串,用来验证解码结果pdu.h是整个项目的契约文件,编解码两边都依赖它。里面通常定义两个核心结构体:一个描述短信的语义信息(号码、编码类型、时间戳、正文),另一个描述 PDU 的原始字节缓冲。把语义和字节流分开,是这类协议解析项目最值得学的一点——编码时从语义结构体往字节流填,解码时反过来,两边逻辑对称,调试时容易定位是哪一层出了问题。
pdu_utils.c是真正藏细节的地方。BCD 号码转换、GSM 7bit 压缩、UCS2 大端序处理,这些容易写错的活都集中在这里。我一般会把工具函数单独放一个文件,因为编解码两边都要调用,放一起改一处就够,不会出现编码改了解码没改的翻车情况。
2.2 技术选型:纯 C 的取舍
为什么不用现成的库?这是很多人拿到这个 demo 的第一个疑问。答案在适用场景里:嵌入式设备上跑的东西,Flash 和 RAM 都紧张,一个成熟的短信协议库动辄几百 KB,而这个 demo 编译出来通常只有几十 KB。纯 C 还有一个好处是可移植,不依赖 libc 之外的任何东西,换到裸机环境或者 RTOS 上,把标准库调用替换掉就能跑。
代价也很明显:字符串处理、内存管理全得自己来。比如十六进制字符串转字节数组,你得自己写循环;动态内存能不用就不用,尽量用固定大小的栈上缓冲。这个 demo 里常见做法是定义宏来限定最大长度,比如#define MAX_PDU_LEN 256,所有缓冲都按这个开,避免 malloc 带来的碎片和失败处理。
提示:如果你的目标平台有完整 libc,可以适当用
snprintf、memcpy这类函数;如果是裸机,记得确认这些函数有没有被裁剪掉。
2.3 编译与运行:三步跑起来
跑通这个 demo 不需要复杂的环境,Linux 或者装了 MinGW 的 Windows 都行。步骤就三步:
# 第一步:进入项目目录,直接 make make # 第二步:解码一条样例 PDU,验证环境是否正常 ./pdu_demo -d "07913386001010F0000B913386001010F00000426061915365000A0CC8F71D14969741F977FD07" # 第三步:编码一条短信,看输出的 PDU 串 ./pdu_demo -e "13800138000" "hello pdu"第一条命令编译,Makefile 里其实就是gcc -Wall -O2 -o pdu_demo main.c pdu_encode.c pdu_decode.c pdu_utils.c。-Wall一定要开,这类字节操作代码最容易出隐式类型转换的警告,关掉警告等于把 bug 藏起来。
第二条命令是解码,-d后面跟 PDU 串。正常输出会打印出号码、时间戳和短信正文。如果输出乱码或者报长度错误,先检查 PDU 串有没有多空格或者少字符——PDU 是十六进制,长度必须是偶数。
第三条命令是编码,-e后面跟目标号码和正文。输出是一串十六进制,你可以拿它跟test_data/里的样例对比,看格式对不对。
注意:不同平台换行符和命令行引号处理不一样,Windows 下如果参数带空格,记得用双引号把整条短信括起来。
3. 核心模块拆解:一条短信从可读文本到 PDU 字节流的完整链路
3.1 编码入口:参数校验与结构体填充
编码的起点在pdu_encode.c,对外暴露的函数通常长这样:
/* pdu.h 中的接口声明 */ int pdu_encode(const char *number, const char *text, char *out_pdu, size_t out_len); /* pdu_encode.c 中的实现骨架 */ int pdu_encode(const char *number, const char *text, char *out_pdu, size_t out_len) { sms_info_t info; memset(&info, 0, sizeof(info)); /* 1. 号码处理:去掉 + 号,判断国际/国内格式 */ if (parse_number(number, &info) != 0) { return -1; /* 号码非法,直接返回错误码 */ } /* 2. 正文编码判断:纯 ASCII 走 7bit,含中文走 UCS2 */ if (is_ascii_only(text)) { info.dcs = DCS_7BIT; info.text_len = strlen(text); } else { info.dcs = DCS_UCS2; info.text_len = utf8_to_ucs2_len(text); } /* 3. 按 PDU 格式逐段写入输出缓冲 */ return build_pdu(&info, text, out_pdu, out_len); }这段代码的逻辑很直白:先把外部输入翻译成内部语义结构体,再统一走build_pdu拼字节流。参数说明一下,number是目标号码,带不带+都要能处理;text是短信正文,UTF-8 编码;out_pdu是调用方提供的输出缓冲,out_len是它的容量。返回值约定 0 成功、负数失败,调用方根据返回值决定要不要打印错误。
我特别看重parse_number这一步。号码格式在 PDU 里是 BCD 编码,而且是半字节交换的——比如13800138000会变成31 08 31 00 80 F0这种形式。如果这里没处理好,后面全错。常见做法是先判断首位是不是+,是的话在国际格式标志位置 1,然后把号码按两位一组打包,奇数位补F。
3.2 GSM 7bit 打包:最容易写错的一段
纯英文短信走 GSM 7bit 编码,这是整个项目里最烧脑的部分。它不是简单的每字符 7 位,而是把 7 位码流连续排列,再按 8 位一组切出来。看代码:
/* pdu_utils.c:GSM 7bit 打包 */ int gsm7bit_pack(const unsigned char *src, int src_len, unsigned char *dst, int *dst_len) { int i, bit_pos = 0; int out_bytes = (src_len * 7 + 7) / 8; /* 向上取整 */ memset(dst, 0, out_bytes); for (i = 0; i < src_len; i++) { int byte_idx = bit_pos / 8; /* 当前写到第几个输出字节 */ int bit_off = bit_pos % 8; /* 在字节内的偏移 */ /* 当前字符的 7 位塞进去,可能跨两个输出字节 */ dst[byte_idx] |= (src[i] << bit_off) & 0xFF; if (bit_off > 1) { dst[byte_idx + 1] |= (src[i] >> (8 - bit_off)) & 0xFF; } bit_pos += 7; } *dst_len = out_bytes; return 0; }逻辑说明:bit_pos记录当前已经写了多少位,每处理一个字符加 7。byte_idx和bit_off算出这个字符的起始位落在哪个字节的哪个位置。因为 7 位可能跨越字节边界,所以当bit_off > 1时,剩余的高位要写到下一个字节。参数src是 GSM 7bit 字符表映射后的码值数组,不是原始 ASCII,这一步之前通常有个查表转换。
这里有个血泪经验:src_len是字符个数,但输出字节数是(src_len * 7 + 7) / 8,不是src_len。我第一次改这段代码时按字符数开缓冲,结果最后几个字符总是丢,查了半天才发现是缓冲开小了。另外dst一定要先清零,因为用的是按位或,不清零会把栈上的垃圾数据带进来。
3.3 解码方向:从字节流还原短信内容
解码是编码的逆过程,入口在pdu_decode.c:
/* pdu_decode.c:解码主流程 */ int pdu_decode(const char *pdu_hex, sms_info_t *out) { unsigned char raw[MAX_PDU_LEN]; int raw_len; /* 1. 十六进制字符串转字节数组 */ raw_len = hexstr_to_bytes(pdu_hex, raw, sizeof(raw)); if (raw_len < 0) { return -1; /* 含非法字符或长度为奇数 */ } /* 2. 解析 SMSC 地址段,跳过 */ int pos = parse_smsc(raw, raw_len); /* 3. 解析首字节:TP-MTI 判断是收短信还是发短信 */ int first_octet = raw[pos++]; int is_submit = (first_octet & 0x01) ? 0 : 1; /* 4. 依次解析号码、编码类型、时间戳、正文 */ pos = parse_number_field(raw, pos, out); pos = parse_dcs_and_timestamp(raw, pos, out, is_submit); return parse_user_data(raw, pos, out); }逻辑说明:hexstr_to_bytes负责把用户输入的十六进制串转成字节数组,遇到非十六进制字符或者奇数长度直接返回 -1。parse_smsc跳过短信中心地址,这段对读短信内容没用但必须跳过,长度是动态的,不能写死。first_octet的最低位是 TP-MTI,用来区分这条 PDU 是手机发出的(SUBMIT)还是收到的(DELIVER),两者的字段顺序略有不同,时间戳位置也不一样。
参数out是调用方传入的结构体指针,解码结果全部填在这里。返回值同样是 0 成功、负数失败。我一般会在每个parse_xxx函数里都做边界检查,因为 PDU 串是外部输入,长度不可信,不检查就是给缓冲区溢出留门。
3.4 时间戳与 UCS2:两个容易忽略的细节
时间戳在 PDU 里是 7 个字节的 BCD,格式是年 月 日 时 分 秒 时区,而且年、月这些也是半字节交换的。比如26 06 19 15 36 50解出来是 2026 年 6 月 19 日 15 时 36 分 50 秒。时区字节是补码表示,00表示东八区之外要换算,这个 demo 里通常直接按原值打印,二次开发时如果需要本地时间,得自己加偏移。
UCS2 编码的中文短信,正文是每字符两字节大端序。解码时要把两个字节合成一个unsigned short,再转成 UTF-8 输出。常见做法是写一个ucs2_to_utf8函数,处理基本多文种平面内的字符就够了,emoji 那种代理对可以暂时不管,除非你的业务真需要。
提示:GSM 7bit 字符表和 ASCII 不完全一样,比如
@是 0x00,$是 0x02。如果你的短信里有这些符号,解码前要先查表映射,直接当 ASCII 处理会出错。
4. 避坑与排查:PDU 编解码里那些让人抓狂的问题
4.1 解码出来全是问号或乱码
现象:PDU 串能解析,号码和时间也对,但正文打印出来是一堆?或者方块。
原因:编码类型(DCS)判断错了。DCS 字节的 bit 3-2 决定编码方式,00是 7bit,08是 UCS2。如果代码里写死了按 7bit 解,遇到中文短信就会乱。
解决:在parse_dcs_and_timestamp里严格按位取 DCS 的编码标志,根据结果分派到gsm7bit_unpack或ucs2_decode。别用if (dcs == 0)这种判断,DCS 还有其他位表示压缩、类别,直接等值比较会漏。
4.2 号码少一位或者多一个 F
现象:解码出的号码末尾多一个F,或者奇数位号码少了一位。
原因:BCD 号码打包时奇数位补F,解码时没有根据号码长度字段把补位去掉。
解决:号码长度字段(TP-OA 的长度)是以半字节为单位的,解码时先读长度,再按长度取字节,最后判断长度是奇数还是偶数,奇数就把最后一个F丢掉。编码时反过来,奇数位补F。
4.3 编译报错 implicit declaration of function
现象:make时报某个函数隐式声明,或者链接时 undefined reference。
原因:pdu.h里漏了函数原型,或者某个.c文件没加进 Makefile。
解决:所有对外函数都在pdu.h里声明,所有.c文件都列进 Makefile 的源文件列表。改完头文件记得make clean再make,有时候依赖没更新会用旧的目标文件。
4.4 长短信解码只出来第一段
现象:一条超过 70 个字符的英文短信或者超过 70 个字符的中文短信,解码只显示前半段。
原因:长短信在 PDU 里是分片的,靠 UDH(用户数据头)里的参考号和分片序号重组。这个 demo 定位是单条编解码,通常不处理 UDH 重组。
解决:如果你的业务需要长短信,得在解码层之上再加一个重组模块,按参考号缓存分片,收齐了再拼。这个 demo 里可以先在parse_user_data里把 UDH 长度读出来跳过,至少保证正文偏移正确。
4.5 编码后对方收不到或者显示空白
现象:自己编码的 PDU 发出去,对方收到空白短信或者直接失败。
原因:SMSC 地址段没填对,或者 TP-MR(消息参考号)用了固定值导致重复。
解决:SMSC 一般填00表示用默认短信中心,或者填运营商给的号码。TP-MR 每次发送递增,别写死 0。另外检查 TP-DCS 和 TP-UDL 是否匹配,UDL 是正文长度,7bit 时是字符数,UCS2 时是字节数,填错会导致正文截断。
5. 二次开发改造:把这个 demo 嵌进你自己的工程
5.1 接口封装成可复用模块
这个 demo 的main.c是命令行入口,真正要嵌进工程时,你只需要pdu.h、pdu_encode.c、pdu_decode.c、pdu_utils.c这四个文件,把main.c丢掉。然后在你的代码里这样调:
#include "pdu.h" /* 发送方向:把用户输入转成 PDU 串,交给串口或模组 */ char pdu_buf[512]; if (pdu_encode("13800138000", "test message", pdu_buf, sizeof(pdu_buf)) == 0) { send_at_command("AT+CMGS=...\r\n%s", pdu_buf); } /* 接收方向:模组上报的 PDU 串,解出内容 */ sms_info_t info; if (pdu_decode(received_pdu, &info) == 0) { printf("from %s: %s\n", info.number, info.text); }改造要点:把printf换成你的日志宏,把固定缓冲大小按你的平台调整。如果平台没有memset、strlen,自己实现或者用编译器内建版本。sms_info_t里的text字段如果是固定数组,注意长度够不够放最长短信。
5.2 扩展字符集与编码支持
默认只支持 GSM 7bit 和 UCS2,如果你的业务要发带特殊符号的短信,得扩展 GSM 7bit 字符表。常见做法是维护一张 128 项的映射表,把 ASCII 和 GSM 扩展字符都覆盖到。UCS2 方向如果要支持 emoji,需要处理代理对,把 4 字节 UTF-8 转成两个unsigned short。
我一般会先写一个单元测试,把test_data/里的样例全部跑一遍,确认改动没破坏原有功能,再加新字符集的用例。没有测试就改协议代码,等于闭着眼睛开车。
5.3 验证方法与调试技巧
验证编解码是否正确,最直接的办法是拿真实手机发一条短信,把模组输出的 PDU 串抓下来,用这个 demo 解码,看内容对不对。反向验证就是自己编码一条,发给手机看能不能正常显示。
调试时我习惯在build_pdu和parse_user_data里加临时打印,把中间字节流按十六进制 dump 出来,跟预期逐字节对比。PDU 格式是固定的,哪一段对不上,问题就出在那个解析函数里。另外记得用-g编译,配合 gdb 看变量,比满屏 printf 高效。
注意:不同模组对 PDU 格式的细节处理有差异,比如有些模组要求 SMSC 段必须填具体号码,有些接受
00。移植时先拿模组文档确认,别直接照搬 demo 的默认行为。
5.4 一个具体技巧:用查表替代分支判断
编解码里大量出现“根据 DCS 选编码方式”“根据字符选 7bit 码值”这类分支。如果性能敏感,可以把这些判断改成查表。比如 GSM 7bit 映射,建一个 256 字节的数组,gsm_table[ascii_char]直接拿到码值,比一堆if-else快也更好维护。DCS 解析同理,建一个结构体数组,每项存编码类型和对应的解码函数指针,新增编码方式时只加一行表项,不用改主流程。
这个技巧是我从一次性能优化里学到的。当时在资源受限的平台上,分支预测失败带来的开销比想象中大,改成查表后解码耗时降了一截。从那以后我每次写协议解析代码,都会先想想哪些分支能变成表,能变就变,代码短了,bug 也少了。希望这个思路帮到你。
本文还有配套的精品资源,点击获取