资讯动态

PDU短信编解码C语言实现:一套代码吃透7bit与UCS2

发布时间:2026/10/21 1:58:17 来源:尧图企业网站定制
简介面向 GSM 网络下短信协议开发者的一套 PDU 短信编解码 C 语言示例覆盖编码与解码两条链路重点说明 SCA、DA、DCS、UD 等 PDU 核心字段以及 7-bit、UCS-2 等常用数据编码方案。资源包共 13 个文件以 C/C 头文件与源文件为主体辅以 Word、PDF 说明文档整体约 618KB配合 CMPP3.0 长短信实现方案适合短信网关、嵌入式通信模块及高校移动通信课程设计等场景。目前已有 652 人学习浏览代码包括编码、解码、辅助转换与命令行主程序可直接编译运行验证文本短信与 PDU 报文互转流程也可在 GSM 长短信收发、自定义短信应用的基础上做二次开发是一份兼顾原理讲解与工程参考的轻量级示例。1. PDU短信编解码demoC语言实现一套代码吃透短信收发的关键编码一条AT指令能发出中文短信靠的是把文本编成PDU读一条GSM模块收到的短信靠的是把PDU十六进制串解回文本。这个C语言demo就是把这两件事做成了能直接编译的工程编码和解码双向都有入口是一个简单的命令行测试程序。项目不依赖任何第三方库核心逻辑就是BCD号码对调、7bit比特流拼装、UCS2与UTF-8互转这三件事。适合两类人一类是在单片机上做GSM模块驱动、被Text模式乱码折腾到怀疑人生的嵌入式开发者另一类是搞协议解析或网关对接需要把PDU格式弄明白的后端开发者。跑通它之后你能得到一条可以直接交给ATCMGS指令使用的十六进制PDU也能把模块吐出来的PDU还原成短信内容和时间。2. PDU报文格式与工程结构从一段十六进制串到字段级拆解2.1 一段PDU里到底藏着什么字段先看一条发往13800138000、内容为hello的完整PDU使用SIM卡默认短信中心00 11 00 0D 91 68 31 08 10 83 00 F0 00 00 AA 05 E8 32 9B FD 06拆开看每个字节的职责00SMSC长度。为0表示直接使用SIM卡里保存的短信中心号码这是嵌入式项目里最省事的做法。11TP层首字节。低两位是MTI01表示SMS-SUBMITbit4为1表示VPF使用相对有效期的格式。00消息参考号MR一般固定为0。0D目的号码长度十进制13代表后面号码有13位有效数字。91号码格式91表示国际格式号码隐含86前缀。68 31 08 10 83 00 F0这是8613800138000的BCD编码。每两个数字放进一个字节前一个数字在高四位、后一个在低四位所以86变成6813变成31。00PID协议标识普通短信一律填0。00DCS数据编码方案0x00表示GSM 7bit编码。AA有效期VP相对格式下这个值代表3天。05UDL用户数据长度。在7bit模式下这个值表示可编码的字符个数也就是hello正好5个字符不是字节数。E8 32 9B FD 06hello经过7bit压缩后的结果。5个字符被压缩成5个字节正好满负荷占满。再给一条接收方向的PDU内容是中文“你好”发送方同样是1380013800008 91 68 31 08 20 07 05 F0 00 0D 91 68 31 08 10 83 00 F0 00 08 21 12 31 23 52 10 00 00 04 4F 60 59 7D字段含义和上面基本对称08 91 683108200705F0SMSC字段长度8字节中心号码为8613800270500。00首字节MTI00表示这是一条SMS-DELIVER也就是模块收到的短消息。0D 91 683108108300F0发送方号码字段。00PID0表示普通短信。08DCS0x08表示用户数据是UCS2编码。21 12 31 23 52 10 00服务中心时间戳SCTS7个字节分别表示年、月、日、时、分、秒、时区BCD编码解析出来是2021-12-31 23:52:10。04UDL4UCS2模式下这个值表示字节数。4F 60 59 7DUCS2编码的“你好”每个中文字符占两个字节。PDU的格式并不复杂关键是要分清每个字段的字节边界和UDL在不同编码方案下的含义这两点最容易写错。2.2 两种数据编码方案怎么选短信用户数据有三种编码方式demo里用到了其中最常见的两种8bit一般用于传输数据或铃声图片这里不展开。编码方式DCS值支持字符UDL含义单条短信容量GSM 7bit0x00英文、数字、GSM基础字符集字符个数160字符8bit0x04任意二进制数据字节数140字节UCS20x08中文、日文、韩文等Unicode字符字节数70字符选择逻辑很简单待发送内容里只要出现非GSM 7bit字符集覆盖的字符就切到UCS2。比如设备状态上报“temp25.6”这种纯英文数字用7bit设备告警“温度过高请检查”这种中文用UCS2。需要注意UCS2方案下70字符的上限是140字节除以2得来的实际能放的中文就是70个字符。超过这个长度就需要做长短信拼接用TP-UDHI在用户数据头里插入8位参考号demo没有实现这个部分但接口留了扩展余地。2.3 demo目录与核心数据结构项目的目录结构很常规适合直接复制到嵌入式工程里裁剪pdu_demo/ ├── Makefile ├── pdu.h ├── pdu_encode.c ├── pdu_decode.c ├── pdu_util.c └── main.cpdu.h定义了编码和解码两个核心结构体分别对应发送方向和接收方向typedef struct { char smsc[24]; // 短信中心号码为空则PDU里写00 char dst_addr[24]; // 目标号码如13800138000 unsigned char msg_ref; // 消息参考号 unsigned char dcs; // 编码方案0x007bit0x08UCS2 unsigned char vp; // 有效期 const char *text; // 待发送文本 unsigned char udl; // 用户数据长度 char pdu[512]; // 组包后的PDU十六进制字符串 } pdu_send_t; typedef struct { char smsc[24]; // 短信中心号码 char sender[24]; // 发送方号码 unsigned char dcs; // 编码方案 char scts[32]; // 格式化后的时间戳 char text[512]; // 解码后的文本 unsigned char udl; // 用户数据长度 } pdu_recv_t;结构体的设计刻意简化每个字段都能对应到PDU里的一个区域方便调试时对照报文看。pdu_encode.c负责发送方向的全部逻辑pdu_decode.c负责接收方向pdu_util.c放hex转换和BCD工具函数。这个分工后续做二次开发时也清晰编码和解码两侧独立修改互不影响。3. 编码流程拆解文本如何变成ATCMGS能收的十六进制串3.1 号码BCD编码号码编码是PDU里最直观的一步但第一次写的人很容易栽在奇数长度上。函数要求传入不带号、不带86前缀的号码返回有效数字位数。int encode_phone_number(const char *phone, unsigned char *out) { int i, n strlen(phone); int pos 0; for (i 0; i n; i 2) { unsigned char hi phone[i] - 0; unsigned char lo (i 1 n) ? (phone[i 1] - 0) : 0x0F; out[pos] (hi 4) | lo; } return n; }逻辑说明每两个数字组成一个字节第一个数字放高四位第二个数字放低四位。比如86这个组合8放到高四位、6放到低四位得到的字节就是0x68。这就是为什么PDU报文里看到的号码总是“对调”的。如果号码位数是奇数最后一组只有一个数字低位用0x0F补齐占位。返回的n用于填充PDU里的号码长度字段。参数说明phone是去掉86前缀后的纯数字字符串out是输出缓冲长度至少能容纳(n1)/2个字节。这个函数只处理数字字符调用前要确认号码里没有空格、括号、短横线这类格式化符号AT指令模块通常不接收带格式符号的号码。3.2 7bit压缩编码7bit编码是整个demo里最值得细看的函数。GSM 7bit编码不是把字符按7bit字节直接存储而是把所有字符的7个有效bit首尾相连成一条连续bit流再从bit0开始按8bit切分成字节。int gsm_7bit_encode(const unsigned char *in, int in_len, unsigned char *out) { int i; int byte_idx, bit_pos; memset(out, 0, (in_len * 7 7) / 8); for (i 0; i in_len; i) { unsigned char val in[i] 0x7F; bit_pos (i * 7) % 8; byte_idx (i * 7) / 8; out[byte_idx] | (val bit_pos); if (bit_pos 1) { out[byte_idx 1] | (val (8 - bit_pos)); } } return (in_len * 7 7) / 8; }逻辑说明第i个字符在bit流里的起始位置是i乘7bit_pos是它落在当前字节内的偏移byte_idx是它起始的字节下标。val先左移bit_pos写入起始字节如果字符的bit跨过了字节边界剩下的高位通过右移(8 - bit_pos)写入下一个字节。比如第2个字符起始位置在bit7bit_pos7低1位写入第一个字节高6位写入第二个字节。参数说明in是待编码的文本in_len是字符个数out是输出字节缓冲。返回值是编码后的字节数等于ceil(in_len * 7 / 8)。这段代码强调一点所有移位操作的变量都要用unsigned char如果val是char类型右移时高位补的是符号位出现负数字节后整个bit流就乱了。3.3 UCS2编码与UTF-8转换UCS2编码模式下每个字符固定占2字节高字节在前。demo从UTF-8字符串出发逐字符解码得到Unicode码点再按大端写入输出缓冲。int utf8_to_ucs2(const char *utf8, unsigned char *ucs2) { int i 0, n 0; while (utf8[i]) { unsigned char c utf8[i]; unsigned int cp; if ((c 0x80) 0) { cp c; i 1; } else if ((c 0xE0) 0xC0) { cp ((c 0x1F) 6) | (utf8[i 1] 0x3F); i 2; } else if ((c 0xF0) 0xE0) { cp ((c 0x0F) 12) | ((utf8[i 1] 0x3F) 6) | (utf8[i 2] 0x3F); i 3; } else { return -1; } ucs2[n] (unsigned char)(cp 8); ucs2[n] (unsigned char)(cp 0xFF); } return n; }逻辑说明ASCII字符直接取值2字节UTF-8编码对应U0080到U07FF取5位和6位拼接3字节UTF-8编码对应U0800到UFFFF取4位、6位、6位拼接。返回的n是UCS2的总字节数这个值直接就是PDU里的UDL。4字节UTF-8编码对应码点超出UCS2范围函数返回-1拒绝处理实际短信里emoji大多无法用UCS2表达做短信能力时要在应用层拦截。参数说明utf8是待转换的字符串ucs2是输出缓冲长度至少为输入字节数的2倍。需要注意UTF-8字符串里的BOM头有些编辑器会把BOM当内容发出去导致短信开头出现一个不可见字符。3.4 组装PDU与长度计算编码函数把前面所有工具函数串起来。组装过程按固定顺序填字节最后转成十六进制字符串。int pdu_encode(pdu_send_t *msg) { unsigned char buf[512]; unsigned char phone_bcd[16]; int len 0, n; buf[len] 0x00; // SMSC长度0表示用默认中心 buf[len] 0x11; // SMS-SUBMIT相对有效期 buf[len] msg-msg_ref; // 消息参考号 buf[len] strlen(msg-dst_addr); // 号码位数 n encode_phone_number(msg-dst_addr, phone_bcd); buf[len] 0x91; // 国际格式 memcpy(buf len, phone_bcd, (n 1) / 2); len (n 1) / 2; buf[len] 0x00; // PID buf[len] msg-dcs; // DCS编码方案 if (msg-dcs 0x00) { buf[len] msg-vp; // 有效期 buf[len] (unsigned char)strlen(msg-text); len gsm_7bit_encode( (unsigned char *)msg-text, strlen(msg-text), buf len ); } else if (msg-dcs 0x08) { unsigned char ucs2[256]; int ucs2_len utf8_to_ucs2(msg-text, ucs2); buf[len] msg-vp; buf[len] (unsigned char)ucs2_len; // UDL填字节数 memcpy(buf len, ucs2, ucs2_len); len ucs2_len; } hex_to_str(buf, len, msg-pdu); return len; }逻辑说明前面部分构造TPDU头部号码部分先写长度、再写91、再写BCD字节。到用户数据部分分岔7bit模式下UDL填字符个数UCS2模式下UDL填字节数。最后用hex_to_str把二进制数组转成可打印的十六进制字符串这才是AT指令里实际发送的内容。参数说明msg-dcs决定走哪条分支msg-text是待发送文本msg-dst_addr是目标号码。组包后msg-pdu是字符串形式的PDUmsg-udl在整个函数里没有显式赋值它只是结构体里保留的调试字段实际发送时UDL只体现在报文里。在真实设备上拿到这条PDU字符串后发送流程是一个经典的AT指令序列ATE0 ATCMGF0 ATCMGS21 0011000D91683108108300F00000AA05E8329BFD06ATCMGS21里的21是PDU的字节数不是十六进制字符串的字符数。CTRLZ发送0x1B取消发送。计算方法是统计组包后所有字节的总长度demo里pdu_encode返回的就是这个值。4. 解码流程拆解收到PDU串后如何还原成短信和时间4.1 十六进制字符串转字节数组解码的第一步是把手里的十六进制字符串还原成二进制数组。这是所有解析工作的基础写错一个字符后面全乱。int hex_str_to_bytes(const char *hex, unsigned char *out) { int len 0; while (hex[0] hex[1]) { unsigned char hi hex_to_nibble(hex[0]); unsigned char lo hex_to_nibble(hex[1]); out[len] (hi 4) | lo; hex 2; } return len; }逻辑说明每两个十六进制字符组成一个字节。hex_to_nibble把0-9、A-F、a-f字符转成0到15的数值。返回值是字节总数。如果输入字符串长度是奇数最后一个字符会被丢弃解析前最好对长度做一次合法性校验。参数说明hex是来自AT指令响应的PDU字符串通常以CMT:开头读取时要去掉前缀和引号。out是输出缓冲大小要和PDU字符串长度的一半相当。4.2 头部字段解析从字节数组开始按顺序读字段。发送方号码和短信中心号码的解析逻辑一致都是逆向的BCD还原。void decode_phone_number(const unsigned char *bcd, int bcd_len, char *out) { int i, pos 0; for (i 0; i bcd_len; i) { out[pos] 0 ((bcd[i] 4) 0x0F); if ((bcd[i] 0x0F) ! 0x0F) { out[pos] 0 (bcd[i] 0x0F); } } out[pos] \0; }逻辑说明BCD还原时先取高四位得到一个数字再取低四位如果低四位不是0x0F就说明是有效数字如果是0x0F表示补齐位跳过。编码时补的F会在解码时被正确丢掉。头部解析的主流程按SMSC长度、首字节、号码长度、号码BCD、PID、DCS的顺序依次推进。SMSC长度是第一个字节为0时后面没有SMSC字段直接进入TPDU部分非0时跳过指定长度。int pdu_decode(const char *hex, pdu_recv_t *msg) { unsigned char buf[512]; int len hex_str_to_bytes(hex, buf); int pos 0; int smsc_len buf[pos]; if (smsc_len 0) { decode_phone_number(buf pos 1, smsc_len - 1, msg-smsc); pos smsc_len; } unsigned char first buf[pos]; // 首字节判断消息类型 int addr_len buf[pos]; // 发送方号码位数 unsigned char addr_type buf[pos]; // 地址类型91是国际格式 decode_phone_number(buf pos, (addr_len 1) / 2, msg-sender); pos (addr_len 1) / 2; buf[pos]; // PID msg-dcs buf[pos]; // DCS if (first 0x01) { // SMS-SUBMIT方向跳过MR和VP字段 pos 2; } else { // SMS-DELIVER方向读取SCTS时间戳 parse_scts(buf pos, msg-scts); pos 7; } msg-udl buf[pos]; if (msg-dcs 0x00) { gsm_7bit_decode(buf pos, msg-udl, (unsigned char *)msg-text); } else if (msg-dcs 0x08) { ucs2_to_utf8(buf pos, msg-udl, msg-text); } return 0; }逻辑说明smsc_len为0时pos不移动第一个字节本身就是TPDU首字节。DCS字段后面需要区分方向发送方向的PDU里有MR和VP两个字段接收方向没有取而代之的是7字节的SCTS时间戳。这个分支判断是解码里最容易漏掉的细节。参数说明hex是完整PDU字符串msg是输出结构体。函数返回0表示成功后续可以扩展返回值区分协议错误和字符集错误。4.3 数据解码与时间戳解析时间戳的解析很简单因为SCTS的每个字节本身就是两位BCD数直接按十六进制格式输出即可。void parse_scts(const unsigned char *p, char *out) { sprintf(out, 20%02x-%02x-%02x %02x:%02x:%02x, p[0], p[1], p[2], p[3], p[4], p[5]); }逻辑说明p[0]是年份的BCD值比如0x21输出为21拼上20前缀得到2021年。p[1]到p[5]依次是月、日、时、分、秒。第7个字节是时区demo不处理直接忽略。这个写法依赖BCD存储格式如果某些短信中心返回的时间戳高低半字节做了交换解析结果会完全错乱排查时需要先打印原始字节对比。UCS2转UTF-8是编码的逆过程每两个字节还原成一个Unicode码点再按UTF-8规则输出int ucs2_to_utf8(const unsigned char *ucs2, int len, char *utf8) { int i, n 0; for (i 0; i len; i 2) { unsigned int cp (ucs2[i] 8) | ucs2[i 1]; if (cp 0x80) { utf8[n] (char)cp; } else if (cp 0x800) { utf8[n] 0xC0 | (cp 6); utf8[n] 0x80 | (cp 0x3F); } else { utf8[n] 0xE0 | (cp 12); utf8[n] 0x80 | ((cp 6) 0x3F); utf8[n] 0x80 | (cp 0x3F); } } utf8[n] \0; return n; }逻辑说明UCS2值小于0x80时直接输出ASCII小于0x800时输出2字节UTF-8其余输出3字节UTF-8。len来自PDU的UDL已经是字节数所以循环步长是2。返回的n是UTF-8字符串的有效字符数。4.4 命令行测试main.c做了一个非常简单的命令行入口编译后可以直接用来验证编码和解码链路。# 编译 gcc -o pdu_demo main.c pdu_encode.c pdu_decode.c pdu_util.c # 编码发hello到13800138000 ./pdu_demo encode hello 13800138000 # 解码解析一条接收PDU ./pdu_demo decode 0891683108200705F0000D91683108108300F00008021231235210000044F60597D编码输出DCS0x00 UDL5 PDU0011000D91683108108300F00000AA05E8329BFD06解码输出From13800138000 SCTS2021-12-31 23:52:10 DCS0x08 Text你好测试时建议把编码得到的PDU原封不动再丢给decode验证双向一致性。这个闭环测试能覆盖大多数逻辑错误。5. 实测避坑指南PDU编解码常见的5个翻车点5.1 三个最容易错位的编码坑第一个坑是7bit模式把UDL填成了字节数。现象编码hello得到PDU发送后模块返回OK但收到的短信文本明显多出几个字符或者开头多了一个不可见字符。原因GSM 7bit的UDL定义是可编码字符数不是字节数hello有5个字符就填05如果直接填strlen计算出的字节数解码端会试图读出比实际更多的字符。解决7bit分支里UDL固定填字符个数UCS2分支才填字节数。第二个坑是奇数位号码没有补F。现象发给13位号码的PDU对方手机收到的号码少一位比如13800138000变成1380013800。原因13位号码两两分组后会剩一个数字如果低位不填0x0F整个BCD字节的下半部分就是垃圾值解码端会把它当成一个有效数字。解决encode_phone_number函数里已经处理了关键是检查调用层有没有把号码正确传入比如号码里混入空格或短横线会导致长度判断错乱。第三个坑是用了char类型做移位。现象7bit编码后某些字节变成0xFFFF开头解码出来全是乱码。原因带符号的char右移时补的是符号位二进制位流被污染。解决编码和解码函数里所有缓冲都用unsigned char所有位运算的中间变量也显式写unsigned char。5.2 两个上了设备才会遇到的发送坑第四个坑是中文内容走了7bit分支。现象发送中文后AT指令返回OK但手机收到的是一串类似叽里咕噜的西欧字符。原因模块按照DCS0x00把UCS2的字节流当成GSM 7bit去解中文字节被拆成多个7bit字符。解决发送前检测文本里是否有非ASCII字符有就强制把dcs改为0x08并走UCS2分支。判断函数可以复用UTF-8解析的前半段逻辑遇到首个非ASCII字节就返回UCS2。第五个坑是AT指令回显干扰了PDU发送。现象第一次发ATCMGS21后模块没有回显“”或者发送CTRLZ后模块报错。原因AT指令的回显会把输入的字符原样打印回来某些模块回显时机不稳导致PDU数据和回显混在一起。解决发送前先执行ATE0关闭回显然后每次发完ATCMGS等待模块返回“”字符再发PDU最后以0x1A结尾。如果发送失败多试一次会发现第二次能成功这不是玄学而是回显残留导致的节奏问题。提示调试PDU编解码时最有效的工具是把编码结果用decode再解一次。自己编的报文自己解不出来问题一定出在字段长度或UDL单位上。6. 二次开发把demo进化成可复用的短信收发函数库6.1 接口层设计思路demo的pdu_send_t和pdu_recv_t是纯数据模型改造时第一步是把它们和AT指令收发解耦。工程里常见的做法是新增一个sms_send接口内部维护一个状态机从IDLE到WAIT_CMGS再到WAIT_CTRL_Z每个状态对应一次AT指令交互。PDU编解码只负责生成和解析数据串口状态机单独一层两边通过回调函数交换数据。我一般会加一个ctx结构体里面放串口写函数指针、等待超时、重试次数。这样既能接AT指令模块的串口也能方便地在PC上模拟测试。6.2 三个值得扩展的方向第一个扩展方向是长短信拼接。超过70个中文字符时需要在用户数据前加TP-UDHI头插入8位参考号分多条发送接收端按参考号重组。demo的组包函数已经留了UDHI的字段位置在首字节0x11上把bit6置1再在UD前插入6字节的头部即可。第二个扩展方向是状态报告。把首字节改成0x01发送并设置SRR位模块会在短信送达后返回一份状态报告PDU解码逻辑里需要新增对SMS-STATUS-REPORT类型的解析时间戳字段解析可以直接复用parse_scts。第三个扩展方向是字符集映射。GSM 7bit字符表里有一些字符的码点和ASCII不一致最常见的符号实际码值是0x00如果直接用ASCII值参与7bit编码会错位。处理办法是建一张0到127的映射表编码和解码都查表而不是直接用字符的ASCII码。从那以后我每写完一个编解码模块都先构造一组“特殊长度”用例跑一遍空内容、160字符、70个中文、13位和11位号码、含符号的英文全部过一遍编码解码闭环再敢往设备上烧。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑