PDU短信编解码C语言实现:从AT指令到串口调试的完整指南

发布时间:2026/10/21 1:48:00
PDU短信编解码C语言实现:从AT指令到串口调试的完整指南
简介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短信编解码demoC语言是一份可以直接编译运行的参考实现它把SMSC字段处理、7-bit压缩、UCS2中文编解码、长短信UDH拆分这些常见协议逻辑整理成了清晰的C代码。和文本模式相比PDU模式能承载中文、长短信和特殊字符代价是字节序、位偏移、长度计算这些细节一步都不能错。这份资源适合做嵌入式终端、串口调试工具、AT指令协议栈的人。新手可以照着代码理解协议结构熟手可以直接把函数抽出来替换自有实现不必从零处理那些绕不过去的半字节交换和位缓冲逻辑。2. PDU报文拆解SMSC、TPDU与7-bit的存储边界2.1 从AT指令响应到内存里的字节流PDU模式的短信在AT指令里是一串连续的十六进制字符模块通过 ATCMGL、ATCMGR、ATCMGS 这些命令和外部交换数据。解码的第一步不是直接进字段解析而是先把串口收到的松散文本转成内存里的连续字节。因为AT响应除了PDU本体还会带记录序号、状态、号码、时间戳这些头部比如CMGL: 1,REC READ,10086,24/11/07 10:30:008 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为MTIbit6为UDHIbit2为RP等标志地址长度1字节目的号码的十进制位数地址类型1字节一般为0x91地址内容可变目的号码BCD编码PID1字节协议标识普通短信为0x00DCS1字节编码方案0x00为7-bit0x08为UCS2SCTS/DCS时间7字节服务时间戳仅接收短信时存在UDL1字节用户数据长度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之间进来后加上8bit_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_tstatic 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为1UD前6字节是UDHUDL需减去6 */ return 0; }入口里最需要注意的是UDH。当首字节的bit6UDHI为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指令如下ATCMGStpdu_byte_len PDU hex string0x1A这里的长度参数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编码把字节数组转成可打印的十六进制字符串然后追加CTRLZ0x1A作为短信结束符。串口层发送完PDU后等待“CMGS: ”或OK如果收到ERROR优先检查长度参数和CTRLZ是否完整。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[i21]重新组合。这套逻辑在大小端平台上行为一致移植到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 ATCMGS长度参数和CTRLZ的发送时机现象串口工具发送ATCMGS后模块没有回“”或者回了提示符但发完数据一直等不到OK最后超时返回ERROR。原因CMGS后面的长度参数写错把SMSC部分也算进去了或者还没等到模块的“”提示符就急着发PDU字符串模块把后续内容当成AT命令解析还有一种情况是数据发完后没发CTRLZ0x1A模块认为短信内容还没结束。解决发送流程严格按状态机走。先发ATCMGS{len}等待模块返回“”再发PDUPDU发完后立刻追加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卡给模块发一条带中文的回信ATCMGL读出来对比解析结果。真机验证主要确认两件事模块的SMSC是否需要显式配置以及模块对UCS2字节序是否完全遵循标准。进阶用法是把demo改造成一个独立的调试工具链。比如收到ATCMGL输出后自动提取PDU、解码并打印可读文本或者反过来从命令行参数读入号码和正文直接输出可粘贴的PDU字符串。串口层换成termios之后这个工具就能在Linux开发机上直接跑调试GSM模块时比手动查表算PDU高效很多。做协议解析这类工作最怕的就是“感觉对了”而不是“验证对了”。从那以后我每次改编解码代码都强制把回环测试跑一遍再上设备哪怕只是改了一个位运算的细节。这个习惯帮我挡掉过好几次差点上线才暴露的乱码问题成本极低收益却实实在在。希望帮到你。本文还有配套的精品资源点击获取