PAYATHON 2026

解析 7 bit PDU 时,为什么 '@' 被解析为 0x00?

排障赵师傅

0x00 是正确的解包结果。在 GSM 7-bit 默认字母表中,@ 的码值是 0x00;在 ASCII 中,它的码值是 0x40。

当前代码已经完成 septet 解包,接下来还需要把 GSM 字符码转换为输出使用的字符编码。原代码的长度设置和数组读取也需要修正。

为什么 @ 会得到 0x00

SMS 使用的 GSM 7-bit 默认字母表与 ASCII 不同。每个字符占 7 bit,并不代表它使用 ASCII 编码。

例如:

字符GSM 7-bit 码值ASCII 码值
@0x000x40
p0x700x70
=0x3D0x3D

题中多数字符在两种编码中的码值恰好一致,直接输出解包结果也能正常显示,遇到 @ 时才出现差别。

完整的处理过程是:

打包后的 TP-UD 字节
    → 解包为 7-bit septet
    → 按 GSM 字符表转换为字符
    → 编码为 UTF-8 等输出格式

第二步得到 0x00,之后按 GSM 字符表将它映射为 @,最终输出的 ASCII 或 UTF-8 字节是 0x40。在线解析器显示 @,也是经过了字符转换,不能据此认为解包时就应该得到 0x40。

@ 对应的位在哪里

从零开始计数,@ 是正文中索引为 15 的字符。它在打包数据中的起始位置是:

bit_pos  = 15 × 7 = 105
byte_idx = 105 / 8 = 13
bit_idx  = 105 % 8 = 1

它占据 uArr[13] 的 bit 1 到 bit 7。这个字节是 0x00,取出这七位得到的也是 0x00。

0x40 的二进制表示是 1000000,其中的 1 位于 bit 6,在七位范围内,ch & 0x7F 会保留这一位。当前得到零,是因为 GSM 字符表把 @ 编码为零。

题中的 21 个打包字节解包后,应得到以下 24 个 septet:

70 77 3D 31 32 33 34 35 36 71 51 2C
70 73 3D 00 26 3B 3F 21 2D 2F 3A 3B

其中,3D 00 26 对应 =@&。

先修正长度和边界读取

完整 PDU 中的相关字段为:

字段值含义
TP-DCS0x00使用 GSM 7-bit 默认字母表
TP-UDL0x18用户数据长度为 24 个 septet
TP-UDHI0没有用户数据头 UDH

循环次数应为 0x18,即 24 次。原代码设置的 0x19 是 25,会多解包一个字符。

24 个 septet 占用 21 个字节,与 uArr 的实际长度一致:

ceil(24 × 7 / 8) = 21

原代码还会在下面这段逻辑中越界读取:

if (bit_idx > 0) {
    ch |= (uint16_t)uArr[byte_idx + 1] << (8 - bit_idx);
}

当 bit_idx == 1 时,七位数据都在当前字节内,无须读取下一个字节。最后一个字符正好属于这种情况,再访问 uArr[byte_idx + 1] 就会越界。条件应改为:

if (bit_idx > 1)

针对这条短信的修正示例

题中这条短信除 @ 外,其余字符的 GSM 码值都与 ASCII 一致,因此解包后只需额外转换 0x00:

#include <stdint.h>
#include <stddef.h>
#include <stdio.h>

int main(void)
{
    const uint8_t uArr[] = {
        0xF0, 0x7B, 0x2F, 0x26, 0x9B, 0xD1, 0x6A,
        0xB6, 0x78, 0x94, 0x05, 0x9F, 0xF7, 0x00,
        0xA6, 0xDD, 0x2F, 0xD4, 0x7A, 0xE9, 0x76
    };

    /* 来自这条 PDU 的 TP-UDL:0x18。 */
    enum { SEPTET_COUNT = 24 };
    char out[SEPTET_COUNT + 1];

    size_t required_bytes = (SEPTET_COUNT * 7u + 7u) / 8u;
    if (required_bytes > sizeof uArr) {
        fputs("Truncated user data\n", stderr);
        return 1;
    }

    for (size_t i = 0; i < SEPTET_COUNT; ++i) {
        size_t bit_pos = i * 7u;
        size_t byte_idx = bit_pos / 8u;
        unsigned bit_idx = (unsigned)(bit_pos % 8u);

        uint16_t ch = (uint16_t)(uArr[byte_idx] >> bit_idx);

        if (bit_idx > 1u) {
            ch |= (uint16_t)uArr[byte_idx + 1u]
                  << (8u - bit_idx);
        }

        uint8_t septet = (uint8_t)(ch & 0x7Fu);

        /*
         * 仅覆盖题中短信使用的字符。
         * 通用解析器应在这里执行完整的 GSM 字符映射。
         */
        out[i] = (septet == 0x00u) ? '@' : (char)septet;
    }

    out[SEPTET_COUNT] = '\0';
    printf("%s\n", out);

    return 0;
}

对应的文本为:

pw=123456qQ,ps=@&;?!-/:;

通用解析器还要注意什么

将 0x00 替换为 @ 只适用于上面的示例。默认字母表中还有其他与 ASCII 不同的码值,例如 GSM 0x40 表示 ¡。遇到 0x1B 时,需要结合后一个 septet 按扩展字符表解码。通用解析器应完成完整的字符映射,再生成 UTF-8 等输出字节。

解包得到的 septet 数组要用显式长度保存。数组中的 0x00 是有效的 GSM 字符码,C 字符串函数却会把它当作结束标志,因此不能直接将该数组当作 C 字符串使用。应在完成字符转换后再添加字符串终止符。

输出时应使用:

printf("%s\n", out);

直接写 printf(out) 会把短信内容当作格式字符串。短信中一旦出现 %,就可能触发错误的格式解析。

上述位偏移计算适用于这条没有 UDH 的消息。解析带 UDH 的 GSM 7-bit 短信时,还需要处理头部长度及填充位,正文起点不能直接套用这里的零偏移。

备注:内容仅供参考。