纯C手写GSM PDU短信编解码:嵌入式物联网实战
简介这是一份面向嵌入式与通信开发者的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.cGSM 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是 7bit08是 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(ATCMGS...\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 也少了。希望这个思路帮到你。本文还有配套的精品资源点击获取