cJson之环境搭建(一) 快速搭建cJson项目环境
cJson之parse_hex4(三) 4位16进制字符解析
从感觉上看字符串没啥好解析的,获取双引号里面的内容就是了,实际上涉及到转义字符、编码问题,导致有点复杂,所以内容比想的要多,我们先从一些基本概念开始
字符串标记
下面这个字符串cJson能解析出来吗?其实不能,下面是字符串不假,但字符串的内容时引号之间的字符,并不包括引号本身,所以cJson拿到是abcd字符,在测试用例中是知道这些内容的,但在完整的Json数据中,没有一个开始结束标志,它根本不知道从哪开始到哪结束
"hello"
也许你想到用特殊字符来标识字符串的开始和结束,比如下面这样,这样其它库解析就识别不了,而且^字符作为标识后,其本身也识别不了,可能还有其它问题
"^hello^"
而引号作为字符串默认的标识符是较好的选择,但也不能直接添加,需要通过转义字符\"表示是引号本身,传给cJson解析的正确字符串是下面这样的,这样cJson拿到就是\"abcd\",是六个字符,前后两个引号字符作为开始结束标记
"\"hello\""
所以在Json数据中看到的引号就是其字符本身
json
{
"hello": "world!"
}
转义字符
转义字符是以反斜杠'\'为开头的字符,后面跟一个或几个字符,其意思是将反斜杠'\'后面的字符转变成为另外的意义, 除了上面看到的转义字符\",还有下面这些
- 退格符号 \b,同键盘上Backspace键
- 换行并回车 \n、
- 只回车不换行 \r,
- 水平制表符 \t,同键盘上Tap键,
- 表示Unicode编码 \u
- 表示反斜杠本身 \\
- 表示单引号字符 \'
这些看上去是两个字符,实际都只占一个字符
unicode
刚开始的时候只有ASCII字符集,它使用7 bits来表示一个字符,从00000000到011111111总共表示128个字符,这对英语国家来说勉强够有,但对于非字母国家来说就捉襟见肘了,比如我们汉字就有上万,那ASCII不能满足的情况下只好自己搞一套,我们中文字符集编码就有GBK、GB2312,但如果每个国家都搞一套,将会无法跨语言、跨平台,到处是乱码
所以国际组织统一了标准,定义了一个Unicode表,以满足跨语言、跨平台进行文本转换的要求。 表中为世界上每种语言中的每个字符设定了统一并且唯一的数字 (码位code point),范围从0000至FFFF(2的16次方:65535),这个范围叫基本多语言平面(BMP),其中0至127这128个数字表示的字符仍然跟ASCII完全一样。在表示一个Unicode的字符时,通常会用"U+"然后紧接着一组十六进制的数字来表示这一个字符,如下图
这样的Unicode非常适合用UTF-16方式编码的,也就是2个字节表示一个Unicode字符,如0x12ab,问题就是英文、数字这些字符都是一个字节可以表示的,如果数据的主要字符都在ASCII部分,那就吃大亏了。
目前主流是采用UTF-8编码,对于不同范围的Unicode,UTF-8采用的编码方式不一样,最少一个字节,最多四个字节,超过一个字节是有编码规则的,如下图
一般情况下我们设置好UTF-8编码,就不用管了,系统会在存储和显示之间自动编解码,比如存储的是0x0061,显示为'a'。但在解析字符串时,可能会遇到Unicode字符串:"\u0061",这就要将字面上的UTF-16转化成UTF-8格式存储
UTF-16转UTF-8
这种转换是分区间的,"\\u0061"通过parse_hex4函数转换成0x61,其在第一区间就不用其它操作,直接使用。
那如果是"\\u00D7",解析成16进制数字是0xD7,这个值就是code point,查询Unicode表可知是个✖号,那以UTF-8的方式怎么编码呢?
首先判断其在第二区间[0x80,0x7FFF],需要两个字节编码,根据上图,先将右边低六位加上'10'变成字节0x97,再把高5位加上'110'变成另一个字节0xC3,这样就转换成了0xC3、0x97两个字节,当你复制✖号的时候,其实就是复制这两个字节
那这两个字节发给其它平台,它怎么获取其code point呢?
其实是上面的一个逆过程,第一个字节的高三位是'110'标记,第二个字节的高两位是'10'标记,说明这两个字节表示一个Unicode字符,去除这个5个标记位,按顺序合并在一起,共有11位,如果都是1的话,16进制就是0x7FF,这就是为什么上限是0x7FF的原因。高位再添加5个0补齐,最后得到的code point就是0xD7,查询Unicode表可知是个✖号
如果你看parse_string.c中的测试用例,会发现还有这样的字符串"\\uD83D\\udc31",一般来说是挨个处理的,但操蛋的事情来了,随着字符不断增多,原来2个字节的范围不够用了,于是就加了区间[0x10000,0x10FFFF],这个范围叫增补平面(SP)。
那这个区间怎么得来的呢?原来0x0000到0xFFFF区间有一部分预留码位[0xD800,0xDFFF],共2048个,这群聪明蛋把它一分为二,用结对的方式解决增补字符问题,专业术语代理对(surrogate pair),共四个字节
- 第一个16位在0xD800到0xDBFF之间,共1024个,有效位数是低10位,如下图黄色部分
- 第二个16位在0xDC00到0xDFFF之间,共1024个,有效位数是低10位,如下图黄色部分。
上面一对UTF-16不会单独出现,必须成对出现,那能表示的字符个数就是1024x1024个,范围在[0x00000,0xFFFFF],增补范围在BMP范围之上,所以是从0xFFFF+1=0x10000开始的,也就是[0x10000,0x10FFFF],能表示一百多万个字符,代价就是每个得四个字节的空间啦,
UTF-32说整这么费事干嘛,全部都用四个字节编码算了,问题还是一样的,浪费空间啊,大兄弟。
所以如果遇到连续两个Unicode字符串,就得判断是否是代理对,解析后的两个16进制数值为0xD83D、0xdc31满足条件,需要当作整体处理。
那怎么计算其code point?
由于两个数值的起点都不是基于0的,所以需要先去除其起点
- 0xD83D去除起点0xD800
- 0xdc31去除起点0xDC00
怎么去除?一般的做法是直接减,更高效的办法是通过位运行,上面的区间图说过黄色的部分是有效值,将红色的高六位,置为0,而保留黄色的10位就可以了,具体操作就是 & 0x03FF
整个过程分下面三步,还是以0xD83D和0xdc31为例:
- 分别&0x03FF得到0x3D和0x31
- 将第一个有效10位和第二个有效十位如下图拼接起来
- 将拼后的结果+0x10000就得到增补区间的code point了,数值不重要了,是个🐱,我从测试用例中知道的
有了code point,知道在第四区间,知道编码规则,就像之前介绍的方法一样操作,编码成4个字节
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx,
字符串解析
理解了上面的基础知识,就比较容易理解字符串解析的思路,这里不贴全部代码了,采用分段的方式说下重点
- 判断解析的内容是不是引号字符开始的
arduino
if (buffer_at_offset(input_buffer)[0] != '\"')
{
goto fail;
}
- 预估需要多少空间存储解析后的字符,粗略的估计是字符串总长度去除转义字符的数量
ini
// buffer_at_offset(input_buffer)就是字符串起始位置
const unsigned char *input_end = buffer_at_offset(input_buffer) + 1;
size_t skipped_bytes = 0;
while (((size_t)(input_end - input_buffer->content) < input_buffer->length) && (*input_end != '\"'))
{
/* 判断当前字符是不是反斜杠,是的话计个数 */
if (input_end[0] == '\\')
{
// 这句判断有问题 后面会说
if ((size_t)(input_end + 1 - input_buffer->content) >= input_buffer->length)
{
goto fail;
}
skipped_bytes++;
input_end++;
}
input_end++;
}
// 预估的长度, buffer_at_offset(input_buffer)就是字符串起始位置
allocation_length = (size_t) (input_end - buffer_at_offset(input_buffer)) - skipped_bytes;
比如下图,通过引号判断结束符,得到字符串长度为10,有两个反斜杠,所以预估的长度就是8个字节 3. 依次对字符解析,策略如下
- 只要不是反斜杠直接复制
arduino
if (*input_pointer != '\\')
{
*output_pointer++ = *input_pointer++;
}
- 对于反斜杠,需要特别处理,其后面一个字符如果是b、f、n、r、t,赋值对应的转义字符即可,
arduino
switch (input_pointer[1])
case 'b':
*output_pointer++ = '\b';
break;
case 'f':
*output_pointer++ = '\f';
break;
case 'n':
*output_pointer++ = '\n';
break;
case 'r':
*output_pointer++ = '\r';
break;
case 't':
*output_pointer++ = '\t';
break;
- 如果是引号、斜杠、反斜杠就直接复制
arduino
case '\"':
case '\\':
case '/':
*output_pointer++ = input_pointer[1];
break;
- 如果是u,需要解析unicode字符串,这是最复杂的部分,涉及上面说的UTF-16到UTF-8的转换
ini
case 'u':
sequence_length = utf16_literal_to_utf8(input_pointer, input_end, &output_pointer);
首先是获取code point,思路如下:
- 如果遇到的第一个unicode字符串在0xDC00到0xDFFF之间,肯定是异常,只有第二个unicode在这个区间
- 然后判断是不是代理对,代理对要满足条件,然后计算code point;不是代理对就简单了
ini
// 解析字符unicode字符串"\\uab12",+2是跳过\\u两个字符,第三节专门介绍过parse_hex4
first_code = parse_hex4(first_sequence + 2);
if (((first_code >= 0xDC00) && (first_code <= 0xDFFF)))
{
goto fail;
}
/* UTF16代理对 */
if ((first_code >= 0xD800) && (first_code <= 0xDBFF))
{
...
second_code = parse_hex4(second_sequence + 2);
if ((second_code < 0xDC00) || (second_code > 0xDFFF))
{
goto fail;
}
// 去高六位,第一个左移10位,拼接第二个10位,加上0x10000
codepoint = 0x10000 + (((first_code & 0x3FF) << 10) | (second_code & 0x3FF));
}
else
{
// 不是代理对,code point就是解析的结果
sequence_length = 6; /* \uXXXX */
codepoint = first_code;
}
有了code point就开始编码了,编码的规则上面也介绍过了,不同区间主要是首位的掩码不同
- 0xxxxxxx
- 110xxxxx 10xxxxxx
- 1110xxxx 10xxxxxx 10xxxxxx
- 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
ini
if (codepoint < 0x80)
{
/* normal ascii, encoding 0xxxxxxx */
utf8_length = 1;
}
else if (codepoint < 0x800)
{
/* two bytes, encoding 110xxxxx 10xxxxxx */
utf8_length = 2;
first_byte_mark = 0xC0; /* 11000000 */
}
else if (codepoint < 0x10000)
{
/* three bytes, encoding 1110xxxx 10xxxxxx 10xxxxxx */
utf8_length = 3;
first_byte_mark = 0xE0; /* 11100000 */
}
else if (codepoint <= 0x10FFFF)
{
/* four bytes, encoding 1110xxxx(这里注释有误,估计是直接复制上面的,应该是11110xxx) 10xxxxxx 10xxxxxx 10xxxxxx */
utf8_length = 4;
first_byte_mark = 0xF0; /* 11110000 */
}
else
{
/* invalid unicode codepoint */
goto fail;
}
先把最后一个字节到第二个字节给整成这样10xxxxxx,咋整呢?之前演示的方式是取低六位加上'10'就行了,这是为了直观理解,实际代码中没有能添加两个bit方法的,而是通过位操作修改
scss
for (utf8_position = (unsigned char)(utf8_length - 1); utf8_position > 0; utf8_position--)
{
/* 10xxxxxx */
(*output_pointer)[utf8_position] = (unsigned char)((codepoint | 0x80) & 0xBF);
codepoint >>= 6;
}
主要有三步
- 先将code point | 0x80,得到第一个1,如下图所示
- 再将结果 & 0xBF,除了得到第二个0,高位也全部置0,这样就得到符合要求的字节了。也许你觉得这样改变了code point值,其实这两步操作只是使用了code point值,并没有改变其值,因为使用的是 | &,而不是|=、&=。
- 完成编码后,将低六位移出去,这次就改变其值了,使用的是>>=
最后一步就是处理第一个字节了,前面已经根据不同区间得到不同的首字节掩码first_byte_mark,比如第四区间的1111 0000,所以直接按位或操作即可,0xFF是保障高位全部置0
scss
if (utf8_length > 1)
{
(*output_pointer)[0] = (unsigned char)((codepoint | first_byte_mark) & 0xFF);
}
else
{
(*output_pointer)[0] = (unsigned char)(codepoint & 0x7F);
}
像这种在Json中放unicode字符串的比较少见,我刚工作的时候,就有后台开发给我传这样的Json数据,完全看不懂,可读性差,但Json库一般能解析出来,所以显示还是正常的,还不好找后台开发理论。
实际这种Unicode字符串是个中间状态,要显示就得解析并计算其code point,要存储还得使用UTF-8编码成字节,而且一个串"\uxxxx"有6个字符,占六个字节,使用UTF-8编码顶多3个字节。所以没有特殊需求,不要使用这种Unicode字符串。
单元测试
上传parse_string.c测试文件后,修改CMakeLists添加parse_string文件名即可编译运行
cd build
make
cd tests
./parse_string
bash
#添加parse_string文件名即可,如果前面的不想要 可以删掉
set(unity_tests parse_number parse_hex4 parse_string)
单元测试中,尝试了各种类型的字符串,复杂的就是含转义字符和Unicode字符串,下面这个说明一下
- \\b是\\和b两个字符,需要解析,转化成'\b'
- 而\b就是原字符,可以直接使用,其它也一样,常见的应用就是打印语句中的printf("xxxxx \n")换行
swift
assert_parse_string(
"\"\\\"\\\\\\/\\b\\f\\n\\r\\t\\u20AC\\u732b\"",
"\"\\/\b\f\n\r\t€猫");
assert_parse_string("\"\b\f\n\r\t\"", "\b\f\n\r\t");
在前面统计字符个数中,我说源码中这句话有问题,它的作用是判断最后一个字符是不是斜杠,因为斜杠后面必须要有其它有效字符,否则就是异常,但这句话其实是判断不出来的
arduino
// 这句判断有问题 后面会说
if ((size_t)(input_end + 1 - input_buffer->content) >= input_buffer->length)
{
goto fail;
}
如下面图所示,input_end指向最后的一个字符时,它的索引是input_buffer->length - 2,input_buffer->content是开始,索引为0,带入左边式子就是
input_buffer->length-2+1-0 = input_buffer->length -1
input_buffer->length -1 是不可能 >= input_buffer->length的
应该把右边的改成input_buffer->length -1
下面这个用例是捕获最后一个反斜杠,能通过测试不是在统计字符个数时发现的,而是在复制到最后一刻发现的,这个就像下载岛国爱情片,用了几百M,下载到最后一个字节,告诉你:不行,看不了,给删了。
swift
assert_not_parse_string("\"000000000000000000\\");
写在最后
字符串解析是Json解析的一个重要基础,解析数组、对象虽然庞大,但都是以这个为基础的,然后是不断重复罢了。文章涉及到一些编码、二进制、十六进制、位操作,如果5分钟没看懂,不要沮丧,毕竟我写了十几个小时,如果5分钟就看懂了,我会很欣慰,这个时间没白花。