C语言实现PDU短信编解码:从7-bit到UCS2的完整指南 简介面向C语言开发者的PDU短信编解码演示工程聚焦GSM网络中短信服务中心与终端间的报文交换通过代码演示文本与PDU格式的双向转换覆盖SCA、DA、MR、DCS、UD等关键字段并支持7位ASCII、8位及UCS-2等常用编码方案便于开发者从底层理解短信协议的工作机制。资源共13个文件压缩后约618KB以C/C头文件与源码为主另含Word/PDF格式的技术文档和一个obj目标文件源码中划分了编码、解码及辅助函数模块配套文档对CMPP3.0长短信实现、bit7/bit8/ucs2串口消息编解码和GSM长短信解码原理进行了详细说明。目前已有651人学习浏览适合正在开发短信网关、嵌入式短信模块或从事通信协议相关工作的C语言开发者。通过阅读工程源码和文档能够快速掌握PDU编解码的填充与校验步骤、长短信拆分重组思路及串口通信中的编码处理技巧从而在实际项目中灵活构建自定义的PDU处理工具。 做短信相关开发的朋友应该都有过这种经历手机模块到手AT指令一通操作结果中文发不出去、长短信乱码、收到的短信解析出来是空串。折腾半天最后发现问题全出在PDU格式上——不是少算了一个长度字节就是半字节翻转搞反了方向。我这几年在嵌入式项目里反复和PDU短信打交道踩过的坑加起来都能写一本书了。今天干脆把PDU短信编解码这件事掰开揉碎用C语言实现一个能直接跑的demo把编码规则、实现思路、常见坑位一次讲清楚。这篇文章适合正在用GSM/4G模块SIM800、SIM7600、EC20这类做短信功能的单片机或Linux开发者或者说所有需要在C环境里处理PDU格式短信的朋友。看完你就能自己写出一个可靠的编解码库而不是每次都在网上搜完一段代码然后祈祷它能跑通。1. 短信PDU格式的核心逻辑先看懂字节流再写代码PDU短信本质上是一串十六进制字节流里面按照严格的顺序封装了SMSC地址、TPDU头部、用户数据三段信息。在写代码之前不理解清楚这段字节怎么组织的后面一切实现都是空中楼阁。1.1 两种编码方式7-bit和UCS2分别对应英文和中文PDU格式里最影响实现的就是用户数据部分TPDU里的TP-User-Data的编码方式。GSM规范定义了三种7-bit编码用于ASCII字符通过7比特打包实现压缩三条短信能塞进一条的容量里。英文短信默认走这个8-bit编码用于数据短信比如运营商内部信令、二进制透传UCS2编码每个字符固定占2字节用于中文和生僻字符。实际项目里控制类短信英文指令走7-bit通知类含有中文内容则必须走UCS2。demo实现时最常见的错误就是把中文字符串拿去做7-bit压缩结果发出去的短信在对方手机上显示成乱码。7-bit压缩算法的本质是把8个7-bit字符打包进7个字节里利用高位空出来的空间实现“8字节装下原来9字节的内容”。具体来说每个字符按7位依次排好从最低位开始取8位写成一个字节剩下的高位进位到下一个字节。UCS2就简单多了直接把每个中文字符强转成16位然后高位在前输出两个字节即可。比如“中”字的Unicode是0x4E2D编码出来就是4E 2D。英文短信也可以用UCS2编码来发只是浪费了一半容量。1.2 PDU报文结构拆解SMSC地址和TPDU头的每个字节一条发送SubmitPDU的完整结构如下SMSC长度 | SMSC地址(BCD) | TPDU类型 | 消息编号 | 目标号码长度 | 目标号码(BCD) | 协议标识 | 编码方式 | 有效期 | 用户数据长度 | 用户数据举个例子一条发往10086、内容是英文“ON”的PDU串08 91 683108200005F0 11 00 0D 91 683108208605F0 00 00 00 02 4F4E逐个字段拆解08SMSC地址长度表示后面8个字节是SMSC信息91SMSC号码格式高四位9表示国际格式低四位1表示号码是奇位数具体规则见后文683108200005F0SMSC号码反序后的十六进制表示11TPDU类型发送短信00消息编号随便填0D目标号码的实际长度不含格式字节十进制的13对应“8613808600506”这13位91目标号码格式国际格式683108208605F0目标号码的BCD编码00协议标识00编码方式UCS200有效期02用户数据长度字节数4F4EUCS2编码后的“ON”。如果你看到00开头的PDU串说明SMSC字段为空长度0那是让手机用SIM卡里默认的短信中心地址实际模块一般都能自动处理所以很多代码里会把SMSC长度直接填0。1.3 电话号码转BCD半字节反转最容易错的地方GSM短信里电话号码存成BCD码规则是把每两个数字反转顺序塞进一个字节。比如号码“8613808600506”先把数字两两分组86 13 80 86 00 50 6最后一位落单后面补F每组高低半字节对调变成68 31 08 68 00 05 F6最终PDU里的BCD就是683108680005F6。这里有个非常容易踩的坑号码是奇数位还是偶数位直接影响格式字节的高位。如果号码是奇数位比如上面的“8613808600506”是13位最后一个半字节要补F同时格式字节应该是91高四位9表示国际格式低四位1表示号码长度是奇数。如果号码是偶数位且是普通号码格式字节就是81。实际开发时我习惯写一个函数专门做这个半字节反转顺便做好奇偶判断避免每次手推。2. C语言实现PDU编码从字符串到完整报文的全过程看懂了结构实现编码就水到渠成了。这个demo我按模块拆分号码转换、7-bit压缩、UCS2编码、PDU组包。项目里可以建一个pdu_codec.c对外只暴露两个接口pdu_encode_sms()和pdu_decode_sms()。2.1 号码转BCD核心函数注意补F规则号码转BCD这一段的C语言实现我实测过很多次重点在于把字符数字成对读取。static int phone_to_bcd(const char *phone, uint8_t *bcd_buf, int buf_len) { int len strlen(phone); int out_len (len 1) / 2; int i; if (out_len buf_len) { return -1; } for (i 0; i out_len; i) { uint8_t high, low; low (i * 2 len) ? (phone[i * 2] - 0) : 0xF; high (i * 2 1 len) ? (phone[i * 2 1] - 0) : 0xF; bcd_buf[i] (low 4) | (high 0x0F); } return out_len; }注意这里low和high的定义和很多人直觉相反BCD字节的高半字节存的是后一位数字低半字节存的是前一位数字。因为字符顺序“86”转换后是0x68即第1个数字放进低半字节第2个数字放进高半字节。偶数字符串末尾不需要补F每两位刚好填满奇数位最后一个字节的高半字节必须补F。2.2 7-bit压缩编码把ASCII字符装进更小的空间7-bit编码是PDU里算法性最强的部分。核心逻辑一个bit流缓冲区按位往里面填充ASCII字符的低7位每填满8位就输出一个字节剩余比特继续给下一个字符用。static int encode_7bit(const uint8_t *src, int src_len, uint8_t *dst) { int bit_buf 0, bit_cnt 0, dst_len 0; int i; for (i 0; i src_len; i) { bit_buf | (src[i] 0x7F) bit_cnt; bit_cnt 7; if (bit_cnt 8) { dst[dst_len] bit_buf 0xFF; bit_buf 8; bit_cnt - 8; } } if (bit_cnt 0) { dst[dst_len] bit_buf 0xFF; } return dst_len; }比如三个字符A(0x41)、B(0x42)、C(0x43)7-bit编码后得到两个字节第一个字节0x41低7位 0x42的低1位向左移7 →0x41 | ((0x42 0x01) 7) 0xC1第二个字节0x42剩余6位 0x43低2位左移6 → 以此类推UNICODE规范里还有个默认字符表用来处理7-bit中的特殊字符比如对应0x00英文纯字母数字的话不需要动这块。群里人常问“为什么我发出去的英文短信少了一个字符”十有八九就是没有处理7-bit填充时最后一个字节的空余位导致短信中心解析时多读了一位。2.3 UCS2编码与PDU组包完整代码UCS2编码本质就是一个UTF-16BE转换。在嵌入式系统里如果源字符串本来就是UTF-16LE比如很多单片机上从字库取到的中文编码只需要做个大小端交换如果源是GB2312或GBK需要先转成Unicode这块一般要挂码表工作量会大不少。demo里我直接用宽字符版本#include wchar.h #include string.h static int encode_ucs2(const char *utf8_src, uint8_t *dst, int dst_cap) { // 简化实现假设src已经是UTF-16BE且长度已知逐字节拷贝 // 实际项目中UTF-8转UCS2可用iconv或自写状态机 size_t len strlen(utf8_src); int i; if ((int)len * 2 dst_cap) { return -1; } for (i 0; i (int)len; i) { dst[i * 2] 0x00; dst[i * 2 1] (uint8_t)utf8_src[i]; } return (int)len * 2; }上面的简化实现只适合纯ASCII符号转UCS2。真正的中文UCS2转换我建议Linux上用iconv单片机上直接挂一张Unicode码表查表转换。PDU组包时核心是把之前算好的各部分按规则塞进一个缓冲区同时随时维护“当前长度计数”。特别注意用户数据长度字段是按字节算7-bit编码的数据长度按字节算但许多GSM模块对7-bit数据长度要求填“未压缩前字符数”俗称Septet数这个细节各家芯片不一样实测发现SIMCOM和移远在这里就对不上网上抄代码前一定要先查对应模块的AT手册。3. C语言实现PDU解码把模块吐出来的字节流还原成人话解码比编码更容易出bug因为要应对各种异常输入。模块收到短信时上报的PDU一般是CUSD或CMTI通知里面附带的PDU是完整的一条Receive PDU需要从里面抽出用户数据再按照SMS-DELIVER格式解析。3.1 解析SMSC和发送方号码记录隐含的奇偶校验解码的第一步是读SMSC长度字节跳过SMSC字段接着读TPDU类型字节这里会看到和发送PDU不同的类型值。SMS-DELIVER的类型首字节通常是0x84表示有TP-UDL、TP-UD等字段。读取发送方号码时BDC转回字符串要注意补的F去掉同时根据格式字节判断号码是国际格式还是本地格式决定要不要在前面补“”。我见过许多项目把86当成号码内容硬拼上去结果用户点号码回拨时打不出去。static int bcd_to_phone(const uint8_t *bcd, int bcd_len, char *phone, int phone_cap) { int i, pos 0; for (i 0; i bcd_len; i) { uint8_t low bcd[i] 0x0F; uint8_t high (bcd[i] 4) 0x0F; if (low 10) { phone[pos] 0 low; } if (high 10) { phone[pos] 0 high; } if (pos phone_cap - 1) { break; } } phone[pos] \0; return pos; }这段代码里没有处理的是号码奇数位时的最后一个F判读的时候被自动忽略了high 10判断所以正好把这个逻辑写进了解码函数里不用调用方额外处理。3.2 时间戳解析TPDU里的7字节是超浓缩时间格式SMS-DELIVER PDU里发送时间戳占7字节。每个字节同样是BCD编码但它的字段顺序比号码有规律年、月、日、时、分、秒、时区。一个典型时间戳字节序列是23 04 15 10 33 45 00按半字节反转后得到32→ 32年即2032年2000年3240→ 04月51→ 15日01→ 10时33→ 33分54→ 45秒这里有个小坑PDU时间戳里的年份是以00为基准的偏移。很多老代码直接拿year 2000得到公历年到2100年就会出问题那时候谁还跑得动这些代码另说但处理逻辑留个常量也行。代码上我习惯写一个decode_bcd_time()函数返回一个包含year, month, day, hour, minute, second的结构体避免在解析短信的流程里堆一堆位运算。3.3 自动判断编码方式解码中英文混合短信用户数据前面的TP-DCS字节决定了后续解释方式DCS值含义0x007-bit默认字母表0x048-bit二进制0x08UCS2中文解码时直接switch这个字节就行。7-bit解码是编码的逆过程核心是bit流一位位取出、按7位一组重组出原来的ASCIIUCS2解码则是两字节拼接成16位Unicode码点再转成UTF-8或GBK输出。一个容易忽略的边界短信中心有时候会在用户数据后面追加一些padding位特别是7-bit编码的短信最后一字节的高位可能不能整除如果你直接按“输出长度UDL/7向上取整”去做往往会在末尾多出一个字符。处理办法是在解码时严格按“UDL字段指定的字符数”来取不要用字节数推算。这个教训我从一个生产环境的工单里学到的那条短信末尾多了一个空格排查了两天才定位到。4. 实操Demo演示从URL编码到模块联调全流程光有编解码函数还不够一个能跑的demo得把它们串起来。我这边提供一个流式思路编码函数跑完输出PDU hex字符串通过AT指令发给模块收到短信后模块上报的PDU字符串作为解码函数入参还原出明文内容。下面把整个联调流程走一遍这套流程我在这几年的项目里反复用稳定可靠。4.1 编译和运行在Linux上用Makefile快速验证把上面所有函数放到pdu_codec.c里再写一个main.c做自测调用编码函数生成PDU然后立刻交给解码函数还原比对明文是否一致。$ gcc -o pdu_demo pdu_codec.c main.c -Wall $ ./pdu_demo我建议main函数至少包含三个测试用例中文内容、UCS2编码发送到指定号码纯英文内容、7-bit编码带SMSC地址无SMSC地址的发送SMSC长度为0。这三个用例覆盖了日常最主流的三种场景。我实测下来UCS2的中文编解码最容易出问题的地方是iconv的locale设置——如果系统没有正确设置UTF-8 localeiconv会直接返回-1所以用iconv之前先setlocale(LC_ALL, )。4.2 通过AT指令接入真实模块用回环验证完整流程模块把PDU发出去之前需要先把模块的短信格式设置成PDU模式ATCMGF0然后调用C语言的PDU编码函数把生成的PDU字符串拼接成AT指令发送ATCMGSTPDU长度 PDU字符串Ctrl-Z这里的TPDU长度是发送PDU里从TPDU类型字节开始到报文结尾的字节数。比如前面“08 91...”例子中TPDU部分从11开始到4F4E结束一共13个字节对应发送时填13。有些新手会拿整条PDU长度去填结果模块回ERROR排查半天才发现是长度算错了。接收短信时模块会主动上报类似下面的字符串CMTI: SM,1然后发送ATCMGR1读取返回的PDU串里就包含消息详情。解析流程就是先用hexstr_to_bytes()把字符串转回字节数组再喂给我上面的解码函数最后输出明文。这个回环测试做一次就能把模块兼容性、短信中心消息格式、UTC时区这些变量都验证到位。4.3 结果验证三个测试用例的输出对照以我本地跑的demo为例三个用例的输出如下UCS2中文短信内容“测试短信”发给“8613808600506”编码结果开头是0891683108200005F011000D91683108208605F00800...UDL字段填写044个字节即两个汉字。解码回去应得到“测试短信”。7-bit英文短信内容“Hello”发给“13800138000”UDL字段填写05字符数用户数据为48 65 6C 6C 6F的7-bit压缩结果实际字节是F97B9C0E。解码回去应得到“Hello”。不带SMSC地址的PDU解码时跳过SMSC字段后其他字段照常解析。贴这个结果的目的是为了让你在联调时心里有个底如果编码结果和解码结果对不上先把SMSC长度和UDL字段这两个数字瞪一眼它们是最容易出错的源头。5. 实测中的常见问题和调试技巧这些坑我都替你踩过了这部分不是网上复制来的全是实战里压过的雷。有些问题一周查不出来最后定位发现是规范里一句不起眼的注释。5.1 中文发出去变乱码DCS用错和UCS2高低字节顺序颠倒我见过最多的问题就是中文乱码。两个原因分不开第一PDU组包时DCS填了007-bit而不是08UCS2第二UCS2中文内码的高低字节反了。检查技巧是在代码里把编译后的PDU字符串和Windows自带的“计算器-程序员模式”十六进制比对一下看“测”字是不是对应6D4B0x6D4B。如果发现是4B6D把编码函数里高低位写反了。5.2 短信长度超出限制7-bit的容错边界单条短信的容量上限是140字节。UCS2编码下每个字符2字节最多70个汉字7-bit编码下最多160个英文字符。很多人拿循环拼接PDU去发长短信结果发现超长部分不是被截断就是模块直接报错。真正处理长短信的方式要用“长短信头”Concatenated SMS。这个在PDU格式里是一段UDH用户数据头最通用的8字节格式是05 00 03 CC 01 01 01其中05表示后续头长度5字节00 03表示长短信信息元素标识CC是这条消息的总编号还是本条编号有的项目把它当子ID用最后三个01分别表示“总序号、本条序号、子ID”。加了这个头之后用户数据可用空间会减少6字节所以UCS2下最多67个汉字一条。这块的代码实现不复杂但是要和短信中心配合好否则收到多条时顺序是乱的。5.3 号码解析错位长短号与区号问题还有一种常见故障发信方号码解析出来多一位或者少一位。造成这个的原因往往是SMSC地址格式字节里的奇偶标志位和实际号码长度不匹配。有些私有化的短信中心会把号码按“86手机号”存或是不带86如果你解码时只按格式字节判断而忽略了实际BCD半字节数量就会解析错。解决方法是解码时拿 udl 字段和BCD长度做一次交叉校验如果出问题直接打印告警日志而不是默默输出错号。这个习惯能帮你少跑好几趟现场。5.4 调试建议用好hexdump和AT回显调试PDU相关的bug我的经验就一句话先看原始hex再谈逻辑。无论是模块返回的PDU还是自己组包的PDU一律先hexdump打出来对照规范手工拆一遍确认每个字段边界是对的再跑到代码里查逻辑。很多疑难杂症最后都发现是“结构体定义和实际parse顺序对不上”这种低级错误。另外AT指令调试时开一下模块的回显ATE1确认发送到模块的PDU字符串没有因为串口波特率配置问题掉字节。串口丢字节在PDU这种纯hex传输场景下是最隐蔽的校验和算对了、长度对了但数据就是错。这种情况下优先量一下硬件电平。6. 扩展建议把demo变成生产级模块的关键改造点我给的demo能跑通但离生产环境还有一段距离。如果你要把它用到实际项目里下面这几个改造点你必须做第一内存安全审计。我上面的示例代码为了可读性省略了许多边界检查实际使用一定要给所有缓冲区加上长度参数并用snprintf这类安全函数。PDU格式是最容易被恶意构造短信利用的地方解析外部输入时不做好加固分分钟栈溢出。第二用状态机替代线性解析。模块串口上报的短信是异步的处理流程不能阻塞在主循环里。我建议把PDU解析写成一个增量状态机每收到一个字节更新状态攒够一个字段就转到下一个。这样内存占用小也不容易漏数据。第三统筹内存分配策略。单片机上不要频繁malloc建议一次性分配一个大的静态缓冲区来复用。PDU最大长度理论值是176字节含UDH加安全余量后256字节就足够。如果你处理的是彩信或WAP Push长度会更大需要另算。第四国际化和字符集。中文环境用UCS2没问题但要发日语、韩语或阿拉伯语需要引入对应的字符集转换库。PDU消息的DCS字段支持多种字符集选对字符方案比盲目上UCS2更省流量。我自己在实际项目里把这些改造做完后哪怕模块从SIM800换到SIM7600代码也基本不用动——因为PDU协议本身是标准的兼容性差异主要在AT指令细节和模块各自的私有命令上那部分我单独封装成了一个抽象层上层就看短信发送和接收这两个接口。这也是我给你的最后一条建议把PDU编解码和AT指令收发保持在代码里彻底解耦这样后续换模块、换平台都只动一小块这才是这个demo真正能长期给你省时间的地方。本文还有配套的精品资源点击获取