☰
PDU短信编解码C语言实现:一套代码吃透7bit与UCS2
2026/10/21 1:58:16 网站建设 项目流程

简介:面向 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短信编解码demo:C语言实现,一套代码吃透短信收发的关键编码

一条AT指令能发出中文短信,靠的是把文本编成PDU;读一条GSM模块收到的短信,靠的是把PDU十六进制串解回文本。这个C语言demo就是把这两件事做成了能直接编译的工程,编码和解码双向都有,入口是一个简单的命令行测试程序。项目不依赖任何第三方库,核心逻辑就是BCD号码对调、7bit比特流拼装、UCS2与UTF-8互转这三件事。适合两类人:一类是在单片机上做GSM模块驱动、被Text模式乱码折腾到怀疑人生的嵌入式开发者;另一类是搞协议解析或网关对接,需要把PDU格式弄明白的后端开发者。跑通它之后,你能得到一条可以直接交给AT+CMGS指令使用的十六进制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

拆开看每个字节的职责:

  • 00:SMSC长度。为0表示直接使用SIM卡里保存的短信中心号码,这是嵌入式项目里最省事的做法。
  • 11:TP层首字节。低两位是MTI,01表示SMS-SUBMIT;bit4为1,表示VPF使用相对有效期的格式。
  • 00:消息参考号MR,一般固定为0。
  • 0D:目的号码长度,十进制13,代表后面号码有13位有效数字。
  • 91:号码格式,91表示国际格式,号码隐含+86前缀。
  • 68 31 08 10 83 00 F0:这是8613800138000的BCD编码。每两个数字放进一个字节,前一个数字在高四位、后一个在低四位,所以86变成68,13变成31。
  • 00:PID协议标识,普通短信一律填0。
  • 00:DCS数据编码方案,0x00表示GSM 7bit编码。
  • AA:有效期VP,相对格式下这个值代表3天。
  • 05:UDL用户数据长度。在7bit模式下,这个值表示可编码的字符个数,也就是hello正好5个字符,不是字节数。
  • E8 32 9B FD 06:hello经过7bit压缩后的结果。5个字符被压缩成5个字节,正好满负荷占满。

再给一条接收方向的PDU,内容是中文“你好”,发送方同样是13800138000:

08 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 683108200705F0:SMSC字段,长度8字节,中心号码为8613800270500。
  • 00:首字节MTI=00,表示这是一条SMS-DELIVER,也就是模块收到的短消息。
  • 0D 91 683108108300F0:发送方号码字段。
  • 00:PID,0表示普通短信。
  • 08:DCS=0x08,表示用户数据是UCS2编码。
  • 21 12 31 23 52 10 00:服务中心时间戳SCTS,7个字节分别表示年、月、日、时、分、秒、时区,BCD编码,解析出来是2021-12-31 23:52:10。
  • 04:UDL=4,UCS2模式下这个值表示字节数。
  • 4F 60 59 7D:UCS2编码的“你好”,每个中文字符占两个字节。

PDU的格式并不复杂,关键是要分清每个字段的字节边界和UDL在不同编码方案下的含义,这两点最容易写错。

2.2 两种数据编码方案怎么选

短信用户数据有三种编码方式,demo里用到了其中最常见的两种,8bit一般用于传输数据或铃声图片,这里不展开。

编码方式DCS值支持字符UDL含义单条短信容量
GSM 7bit0x00英文、数字、GSM基础字符集字符个数160字符
8bit0x04任意二进制数据字节数140字节
UCS20x08中文、日文、韩文等Unicode字符字节数70字符

选择逻辑很简单:待发送内容里只要出现非GSM 7bit字符集覆盖的字符,就切到UCS2。比如设备状态上报“temp=25.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.c

pdu.h定义了编码和解码两个核心结构体,分别对应发送方向和接收方向:

typedef struct { char smsc[24]; // 短信中心号码,为空则PDU里写00 char dst_addr[24]; // 目标号码,如13800138000 unsigned char msg_ref; // 消息参考号 unsigned char dcs; // 编码方案,0x00=7bit,0x08=UCS2 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. 编码流程拆解:文本如何变成AT+CMGS能收的十六进制串

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是输出缓冲,长度至少能容纳(n+1)/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乘7,bit_pos是它落在当前字节内的偏移,byte_idx是它起始的字节下标。val先左移bit_pos写入起始字节,如果字符的bit跨过了字节边界,剩下的高位通过右移(8 - bit_pos)写入下一个字节。比如第2个字符起始位置在bit7,bit_pos=7,低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编码对应U+0080到U+07FF,取5位和6位拼接;3字节UTF-8编码对应U+0800到U+FFFF,取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是字符串形式的PDU,msg->udl在整个函数里没有显式赋值,它只是结构体里保留的调试字段,实际发送时UDL只体现在报文里。

在真实设备上,拿到这条PDU字符串后发送流程是一个经典的AT指令序列:

ATE0 AT+CMGF=0 AT+CMGS=21 0011000D91683108108300F00000AA05E8329BFD06

AT+CMGS=21里的21是PDU的字节数,不是十六进制字符串的字符数。CTRL+Z发送,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

编码输出:

DCS=0x00 UDL=5 PDU=0011000D91683108108300F00000AA05E8329BFD06

解码输出:

From=13800138000 SCTS=2021-12-31 23:52:10 DCS=0x08 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,但手机收到的是一串类似叽里咕噜的西欧字符。原因:模块按照DCS=0x00把UCS2的字节流当成GSM 7bit去解,中文字节被拆成多个7bit字符。解决:发送前检测文本里是否有非ASCII字符,有就强制把dcs改为0x08并走UCS2分支。判断函数可以复用UTF-8解析的前半段逻辑,遇到首个非ASCII字节就返回UCS2。

第五个坑是AT指令回显干扰了PDU发送。现象:第一次发AT+CMGS=21后模块没有回显“>”,或者发送CTRL+Z后模块报错。原因:AT指令的回显会把输入的字符原样打印回来,某些模块回显时机不稳,导致PDU数据和回显混在一起。解决:发送前先执行ATE0关闭回显,然后每次发完AT+CMGS等待模块返回“>”字符再发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位号码、含@符号的英文,全部过一遍编码解码闭环,再敢往设备上烧。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询