Chaofan's

Bonvenon al la malpura mondo.

标签: C语言

  • 快速了解SIMD

    SIMD即Single Instruction Multiple Data缩写,单指令多数据,表示CPU设计中一种提高程序并行度的技术。和它对应的SISD、MISD和MIMD三个概念已很少有人提及,倒是GPU又引入了个新概念叫SIMT (Single Instruction Multiple Threads)。

    换种理解方式或许更实在些:SIMD和SIMT表示逻辑上有多个执行的实体,但只有一个执行的状态。至于其他三种:

    • 一个执行实体,一个执行状态,属于单线程
    • 一个执行实体,多个执行状态,属于协程
    • 多个执行实体,多个执行状态,属于多线程

    更实用地说,编程用途上的SIMD就是一组指令集扩展,能用一条指令同时完成若干个数据的相同操作。由于大量计算程序最耗费时间的代码都是核心的若干循环,如果能有SIMD指令的帮助,虽然复杂度还是没有改变,但相当于耗时除以一个不小的系数,不算免费午餐也是廉价午餐了。

    快速上手SIMD指令

    这里用C语言举例,看下面的代码。

    #include <stdio.h>
    
    typedef unsigned vecu32 __attribute__((vector_size(16)));
    
    extern unsigned data0[];
    extern unsigned data1[];
    extern unsigned datalen;
    
    int main(void) {
      for (int i = 0; i < 100000; ++i) {
        int j = 0;
        for (; j + 4 <= datalen; j += 4) {
          vecu32 a = *(vecu32*)(&data0[j]);
          vecu32 b = *(vecu32*)(&data1[j]);
          *(vecu32*)(&data0[j]) = a + b;
        }
        for (; j < datalen; ++j)
          data0[j] += data1[j];
      }
    }

    为阻止编译器做常量优化,这里把两个数组的定义放置在另一个文件里,长度都为65536个unsigned。在Apple M1上,使用Apple Clang 16搭配-O选项,运行时间是0.87秒。而如果不使用vecu32,即注释掉中间用到vecu32的for循环,运行时间会来到2.40秒。这里一个vecu32是16字节,即能容纳4个unsigned。虽然没有完全达到4倍性能差距,但2.8倍也是非常明显的性能提升,奥秘就在这里声明的vecu32类型。

    如果你稍微熟悉GCC或Clang,就能发现这里的__attribute__是GCC风格属性扩展,而vector_size属性就是为向量类型准备的。在编译器语境,SIMD容器就被称作向量 (vector),实际上确实比C++的std::vector更接近数学上向量的原始定义。这里一个vecu32能容纳4个unsigned变量,那我能否定义一个32字节的vecu32来进一步加速呢?

    先看当前版本的汇编。在ARM上用clang -S可以发现输出中有一条add.4s v0, v0, v1指令,查询文档可得知这条指令的意思就是向量整数加。再把源码中的16改成32,for循环头上的4改成8编译一遍,会发现执行时间几乎没有变化,而汇编里有了两条 add.4s而没有想象中的add.8s存在。失落之余先别着急,在输出汇编的编译命令中加入-arch x86_64 -mavx,能惊喜地发现X86平台是有32字节SIMD指令的,叫vpaddd。甚至,甚至可以更进一步,把上面代码的向量尺寸改为64字节,循环步长改为8,然后加上-mavx512f选项,会发现X86版本还能有对应的单条指令 (尽管名字还叫vpaddd,但寄存器类型变了,实质上是和前面的vpaddd算不同的指令)。

    这告诉我们一个道理,SIMD指令的支持范围和CPU架构有关,和编译器选项也有关。

    SIMD和CPU架构

    不同CPU架构支持的SIMD长度和类型各有不同,但主流指令集都有面向SIMD的扩展。

    x86

    最早的x86 SIMD扩展指令集叫MMX,来自1996年的Intel,以加速多媒体程序。但它仅支持64位长度,并且只能加速8位到32位的整数操作。因为早期x86 CPU的古怪设计,这个MMX指令不光短,还会占用浮点数的寄存器,用今天的目光看实在鸡肋。AMD也看不下去,推出了名叫3DNow!的扩展,支持浮点SIMD。为了应对,Intel很快推出了新的SIMD扩展,也就是今天有名的SSE (Streaming SIMD Extensions)。

    SSE支持128位长度的向量,且可容纳如4xfloat、2xdouble、4xint、8xshort或16xchar等不同类型。更重要的是,因为过去X86对浮点的支持过于奇葩 (x87指令集),SSE对标量(即单个)浮点数的操作也做了延伸,float和double的操作终于可以对应到和整数相似的指令了。SSE经历了多次扩展,涵盖了整数和浮点数从算术到重排和加密等各种操作。对今天的x86 CPU来说,SSE可以视作默认支持。

    十年后,Intel又发布了新的SIMD扩展,称作AVX (Advanced Vector eXtensions),总体和SSE相似,不过长度又扩展了一倍,支持256位向量。AVX还有更变态的延伸版本叫做AVX-512,顾名思义就是512位向量,8个double或者64个char同时操作。

    ARM

    ARMv7引入了高级SIMD扩展,通常也被称作NEON。和x86的MMX/SSE类似,NEON支持64位和128位两种向量。虽然ARM处理器家族比x86更复杂多样,但今天也基本可以假定,主流ARM芯片都支持NEON指令集。

    一部分面向服务器的ARM处理器,为了支持更长的向量,走了和x86不同的道路,推出了称作SVE (Scalable Vector Extension) 的动态向量扩展。和AVX固定256位、AVX512固定512位不同,SVE没有固定向量长度,而是在向量操作之外,又引入了一组谓词指令和寄存器,这就可以在汇编层面体现上层的循环逻辑,从而在运行时确定向量长度(也就代表着循环次数)。这样做的好处是:一个为SVE编译的二进制程序,在不同向量长度的CPU上都可不经改动执行,自动获得硬件向量变长带来的性能提升。

    SVE支持128到2048位的向量,ARMv9引入的SVE2又加入了若干新指令。本文不计划深入讨论SVE的使用。普通桌面级的ARM CPU (如Apple M1),并不支持SVE。

    RISC-V

    RISC-V把除最基本整数指令外的所有指令都归类为扩展,并以单独的字母标记,如扩展F和D分别表示单精度和双精度浮点数。RISC-V曾经有个叫做P (Packed SIMD) 的扩展,但今天更主流的是扩展V。RISC-V的创造者之一David Patterson (《计算机体系结构:量化研究方法》的作者之一),曾经写过一篇文章批评传统的SIMD指令设计不够灵活,增加了复杂度。

    也因此,RISC-V的扩展V (经常称作RVV) 指令设计更类似ARM SVE。而细节上更加灵活。比如说,向量长度存储在一个特殊寄存器中,计算指令也并不包含元素长度,具体元素多长会由特别指令设置。一条vfadd.vv可能是f32也可能是f64。

    在RISC-V语境中,SIMD向量两个词有明显区分:SIMD指x86风格的定长定类指令,向量指可动态扩展的多数据指令。但在本文其他部分,不作严格区分。

    POWER

    在Mac电脑还在使用PowerPC CPU的年代,苹果、IBM和摩托罗拉组建过所谓的AIM联盟。90年代末还没有今天的GPU概念,多媒体相关的加速都由CPU完成。为了和x86 SSE竞争,AIM在PowerPC指令集上推出了Vector Media eXtension (VMX) 扩展。该扩展引入了一种128位向量类型,支持整数和部分单精度浮点指令。

    PowerPC Mac的绝唱,PowerMac G5,支持该指令集。而后续IBM服务器上的POWER指令集依然包括这个扩展。VMX更出名的名称叫AltiVec,但2004年摩托罗拉半导体部门分拆为飞思卡尔公司,AltiVec商标由飞思卡尔持有。为避免商标纠纷,IBM用到的场合继续称之为VMX。在GCC和Clang等编译器眼中,这个指令集依然叫AltiVec。

    从POWER指令集2.06版开始(即POWER 7),POWER在VMX基础上引入了新的VSX (Vector Scalar eXtension) 指令集。在传统的POWER浮点指令外,VSX加入了一组新的和IEEE-754完全兼容的标量和向量浮点指令,类似SSE。浮点和向量寄存器也统一起来,VSX共64个128位寄存器,传统的32个浮点寄存器成为了前32个VSX寄存器前64位的别名,32个VMX寄存器则对应到后32个VSX寄存器。

    WebAssembly

    虽然WebAssembly不是真实的CPU指令集,但因为设计上考虑性能,加入SIMD指令也有助于模拟和JIT执行。Wasm的SIMD类型相对简单,固定128位,配合不同类型的计算指令,和SSE、NEON、VMX/VSX都能对上。

    编译器与SIMD

    虽然SIMD能给计算密集的程序带来提升,但我们并不是每次都要手写这堆奇怪的语法才能用上SIMD。在开优化的情景下,编译器会努力地将代码中的标量操作组合成向量指令,这部分功能在编译器中称作Vectorizer。LLVM中有循环Vectorizer和SLP Vectorizer两类,前者针对循环而后者针对非循环。其实,开头那个程序如果用O3编译,即使不手工使用向量扩展,Clang也可以生成出向量指令。

    像开头例子中这样简单的循环,循环体内只出现了一次加法,为了「凑」出四个加法构成SIMD,编译器需要像我们手写的代码一样把循环体扩充为原来的4倍,然后让循环次数除以4,并且还要处理剩下几个余数的情况,所以汇编容易变得非常大。这种优化叫做循环展开 (Loop Unrolling)。有关其他自动向量化中可能涉及到的优化,可以查阅LLVM向量化的官方文档

    如果还是有要手写向量化代码的情况,除开头提到的__attribute__((vector_size()))外,每个平台都提供了自己的C语言扩展:

  • 理解Big-endian和Little-endian

    Big-endianLittle-endian,你也许听说过这两个概念,可能也大概知道是什么意思,但未必清楚更深入的细节。这不奇怪,因为不直接和内存打交道的程序员不会对Endian有多少实践层面的理解,而即使写过相关的程序,因为抽象泄露的魔咒,也说不清底层原理。本文试图解释big-endian和little-endian的由来,在执行层面的原理,以及主流高级语言中如何处理Endian相关问题。

    在继续之前,先解释前文提及的概念「抽象泄露」。计算机科学本身可以理解为有关抽象的学问,C语言尽管今天看来相当底层,但也是建立在CPU指令上的抽象,人们用C写的程序,实际上是被编译器转写到机器指令执行。这也意味着,机器指令层面的一些概念,并不能在C语言的模型里体现,即C语言程序的某些问题无法用C语言本身的模型解释,必须依赖更底层的原理。此种情况下,C语言的抽象显得不完备了,这就是抽象泄露。

    典型的抽象泄露例子就是a=a+1语句中,如果变量a不是一个原子(atomic)变量,那么多线程环境中这个语句可能会造成数据不一致。这个问题很难用C语言本身的逻辑理解,我们只知道机器会给a的值加上1(C语言甚至不考虑有符号整数的溢出情况!),但具体CPU是如何计算的,我们不知道。只有当我们知道这个表达式在汇编层面是通过读取内存、执行加法,再写回内存之后能理解数据竞争的起因。这也是我认为编程初学者可以从汇编入门的原因——尽管汇编也有抽象泄露(如预测执行),但总归是比C的模型完备多了,对人也不难理解,C系语言的执行模型实在有点两头不占(另一头是函数式模型)的意思。

    回到正题。来解释big-endian和little-endian的区别。

    什么是Big-endian和Little-endian

    考虑如下一段C程序。

    #include <stdio.h>
    #include <stdint.h>
    uint8_t u8s[4] = { 0x12, 0x34, 0x56, 0x78 };
    uint32_t conv(void) { return *((uint32_t*)u8s); }
    int main(void) { printf("%x\n", conv()); }

    程序把一个4字节数组当作4字节整数读取,输出的内容会是多少呢?按照通常思路,uint32_t占内存中4字节的空间,4个uint8_t也是占4字节空间,那么这个指针强转的结果自然也是16进制的12345678。但实际上,在你的电脑上通常结果是78563412。是的,不是12345678,不是87654321,也不是1e6a2c48。而且这不是什么随机行为,而是基于某种逻辑的固定倒转。

    简单来说,对该程序输出78563412的CPU,就是Little-endian的;而输出12345678,则是Big-endian的。

    这两块内存不都是4字节吗,为什么会有这个区别?一个不精确但通俗的解释是:CPU对聚合类型(数组、结构体)和基本类型的变量有不同的处理方式。

    u8s[0]u8s[1]u8s[2]u8s[3]
    0x120x340x560x78

    以上是u8s这个数组的内存布局,从低地址到高地址排列。对于Big-endian的CPU,从内存读取一个int类型也按照这个顺序。但对Little-endian来说,就要反过来了。

    int[3]int[2]int[1]int[0]
    0x120x340x560x78

    顺序是倒过来的。把以上程序的4字节调整为8字节或2字节,也会得到类似的效果。但注意,这只针对各种内置类型有效,struct {int l; int r;}int[2]有一样的顺序。

    Endian名称的由来

    中文技术资料基本把Endian统一译作「字节序」。在英文语境里,Endian倒是个有典故的词,它来自《格列佛游记》第一卷第四章:

    我下面就要告诉你的是,这两大强国过去三十六个月以来一直在苦战。战争开始是由于以下的原因:我们大家都认为,吃鸡蛋前,原始的方法是打破鸡蛋较大的一端。可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个手指弄破了,因此他的父亲,当时的皇帝,就下了一道敕令,命全体臣民吃鸡蛋时打破鸡蛋较小的一端,违者重罚。人民对此法极为反感。历史告诉我们,由此曾发生过六次叛乱,其中一个皇帝送了命,另一个丢了王位。这些内乱常常是由不来夫斯库国的君王们煽动起来的。骚乱平息后,流亡的人总是逃到那个帝国去寻求避难。据估计,先后几次有一万一千人情愿受死也不肯去打破鸡蛋较小的一端。

    所以你吃鸡蛋时先打哪边?

    代码中的字节序

    C/C++

    由于C和C++支持对变量取地址和任意指针类型的互转,我们很容易在C或C++中遇到字节序问题。结合上面使用C语言的例子,要在C语言里检测当前CPU是little-endian还是big-endian也可以用类似的做法:

    bool is_little_endian(void) {
      uint32_t test = 0x12345678U;
      return ((uint8_t*)&test)[0] == 0x78;
    }

    在Little-endian平台上,编译器会聪明地把这个函数优化成return 1。但这对我们来说并不够,因为我们可能需要在不同字节序的机器上编译不同的代码,运行时判断显然不够。好在,主流编译器都有宏用来判断:

    #ifdef __GNUC__
    #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
    #define LITTLE_ENDIAN 1
    #else
    #define LITTLE_ENDIAN 0
    #elif _MSC_VER || __INTEL_COMPILER
    #define LITTLE_ENDIAN 1
    #end

    Clang会伪装自己是GCC,二者都支持通过__BYTE_ORDER__宏判断字节序。实际上,除了__ORDER_LITTLE_ENDIAN____ORDER_BIG_ENDIAN__外,实际上GCC中还有一种可能取值是__ORDER_PDP_ENDIAN__。PDP是Unix诞生时的CPU架构,其对16位整数采用Little-endian,而对32位整数使用一种奇怪的字节序,即把0x12345678表示为0x34, 0x12, 0x78, 0x56。技术上,现在已不需要考虑这种字节序。

    对于Intel编译器(ICC)和Visual Studio (MSVC),它们只存在于little-endian平台上,所以发现编译器是它们就可以放心地确认字节序。早期版本的Windows NT支持过PowerPC和MIPS这类以big-endian为主的架构,但即使是这些版本的Windows,也是运行little-endian模式的。

    如果要在所有平台上以big-endian字节序读取整数,请使用位运算:

    uint32_t read32be(uint8_t input[]) {
      return ((uint32_t)input[0] << 24) |
             ((uint32_t)input[1] << 16) |
             ((uint32_t)input[2] << 8) |
             (uint32_t)input[3];
    }

    以Little-endian方式读取,请将数组下标倒过来。

    Rust

    Rust中,可使用target_endian指令在编译期判断字节序:

    #[cfg(target_endian = "little")]
    // Code for little endian
    
    #[cfg(target_endian = "big")]
    // Code for big endian

    要以某种字节序读取数据,可使用byteorder crate。

    Java

    尽管主流CPU都是little-endian,但Java大概因为靠近网络字节序(见下文)的原因,统一使用big-endian。通过java.nio.ByteOrder类的nativeOrder方法,Java程序可以得知当前系统硬件上使用哪种字节序:BIG_ENDIAN或者LITTLE_ENDIAN

    DataInputStream采用Big-endian,如果要以Little-endian方式读取数据,可采用同前面C语言相似的位拼接做法。

    JavaScript

    JavaScript这样的脚本语言通常不太会考虑字节序问题,因为我们无法对一个对象取地址,或者查看它的内存布局。但现代JavaScript依然提供了和字节流打交道的API,这里涉及到三个概念:TypedArrayArrayBufferDataView

    简单来说,TypedArray表示某种固定类型作为元素的数组(可以是8种整数和2种浮点其中之一),ArrayBuffer表示无类型的一串字节,DataView是在前者之上读写的适配器:

    let array = new Uint32Array(1)
    array[0] = 0x12345678
    let view = new DataView(array.buffer)
    view.getUint8(0) // => 120 (0x78)
    view.getUint8(3) // => 18  (0x12)
    view.setUint32(0, 0x12345678, true)  // set data as little-endian
    view.setUint32(0, 0x12345678, false) // set data as big-endian
    view.getUint8(0) // => 18  (0x12)

    在JavaScript中,对TypedArray元素的赋值,字节序由CPU的字节序决定,所以在little-endian机器上自然得到little-endian布局的ArrayBuffer。但DataView的set方法有第三个参数表示是否使用little-endian顺序写入,要注意的是,这个参数的默认值是false,也就是big-endian,这意味着在大多数平台上setUint32及其他类似函数的行为,同一般赋值是相反的。

    Ruby

    Ruby也同理,大部分情况下用不着和二进制表示直接打交道。但Ruby中数组类型有将对象转换为二进制字节的pack方法,字符串类型有把字节串解析回对象的unpack方法。这两个方法利用字符串参数指定要读取的类型,除了类型和长度外,不同记号还代表着不同字节序:

    [100].pack 'L' # => 32-bit int in native endian
    [100].pack 'V' # => 32-bit int in little-endian
    [100].pack 'N' # => 32-bit int in big-endian
    
    # double (1.1) in native endian
    "\x9A\x99\x99\x99\x99\x99\xF1?".unpack 'D'
    # double (1.1) in little endian
    "\x9A\x99\x99\x99\x99\x99\xF1?".unpack 'E'
    # double (1.1) in big endian
    "\x9A\x99\x99\x99\x99\x99\xF1?".reverse.unpack 'G'

    自然可以用同样的方法测试当前系统字节序:

    def is_little_endian?
      [1].pack('L') == [1].pack('V')
    end

    不同硬件架构的字节序

    x86

    主流的32位和64位x86都是little-endian字节序。64位x86指令集有时又被称作amd64,因为它最早是AMD发明的。曾经的Intel提出过支持两种字节序切换的IA-64架构作为x86的64位继承者,后来因为不向前兼容而陨落在历史之中。

    x86一直是little-endian,这意味着从最早最简单的ADD指令,到今天复杂的AVX512或者AMX,只要指令的某个操作数是内存地址,那指令就一定会按照低位在前高位在后的逻辑读取数据。

    ARM

    ARM在指令集层面对没有对字节序作强制要求,也就是说,一块ARM CPU可能是little-endian,可能是big-endian,也可能二者皆支持。可以从目标Triple区分:以aarch64_bearm64_bearmeb开头的,即为big-endian,其他不带be或者eb缩写的,则是little-endian。

    为了和x86兼容,主流ARM基本都是little-endian模式。

    RISC-V

    RISC-V是little-endian,尽管有说法曰作者更喜欢big-endian,但因为主流的ARM和x86都是little-endian,因此也选择了little-endian。

    POWER

    POWER诞生在90年代,那时候RISC工作站还不是个历史概念,各大厂商都在制造自家的RISC CPU和UNIX分支,x86还是穷小子,所以大家依然倾向于big-endian。不过为了便于移植x86 PC程序,Windows NT支持的PowerPC 610还的确支持过little-endian模式,然而PowerPC和Windows的合作并没有持续几代,这个开关也被移除,直到POWER 7。

    但POWER 7的little-endian支持并不完整,例如硬件虚拟化就没法支持little-endian模式。从POWER 8开始,POWER CPU开始完整支持little-endian字节序。也就是说,POWER 8及以上的CPU支持两种字节序,由一个特权寄存器控制。甚至理论上,POWER CPU可以在运行时切换字节序。(但显然没人愿意这么做)

    在POWER支持little-endian前,Linux已经支持32位和64位的big-endian POWER CPU。而在little-endian加入以后,因为两种字节序的程序几乎没法相互兼容,所以它们在Triple上被看作两种不同的架构:ppc64指big-endian,而little-endian常用ppc64le或ppc64el表示。

    长远来看,同样是出于兼容目的,Linux将逐渐去除对64位POWER big-endian模式的支持,little-endian才是未来。不过这个故事仅限于Linux和BSD等开源社区,传统的AIX依然只会支持big-endian模式,且在可见的未来还没有改变的迹象。

    一些应用场景的字节序

    Unicode

    UTF-16和UTF-32这两种格式都会采用多个字节表示单个码点(通俗点说就是字符,尽管并不准确),这就引出little-endian和big-endian两种格式。在多数编辑器或处理字符编码的程序里,UTF-16 LE和UTF-16 BE被看作两种编码。但根据Unicode标准,使用UTF-16和UTF-32的文件都需要在开头用2字节标记文件是big-endian还是little-endian:0xFE 0xFF表示big-endian,0xFF 0xFE表示little-endian。

    对于一个内容为「Test」的文件,以下是三种编码的字节内容:

    feff 0054 0065 0073 0074 (UTF-16 BE)
    fffe 5400 6500 7300 7400 (UTF-16 LE)
    6554 7473 (UTF-8)

    开头的这两个字节被称作BOM(Byte-order mark),显然UTF-8因为基本单位是字节,所以并不需要BOM。但一些Windows程序依然会给UTF-8文件加上0xEF 0xBB 0xBF作为BOM以标记这个文件使用UTF-8。

    网络字节序

    根据RFC 1700,socket通信中的数据都使用big-endian格式传输,因此使用socket的C API时,需要用htonl/htons/ntohl/ntohs系列函数将数据在big-endian和原生字节序之间作转换,比如要绑定的端口号。一些上层库可能已经封装了这个行为,但这一点仍需牢记在心。

    WebAssembly

    WebAssembly采用little-endian,但这指的是内存读写指令采用的字节序,WebAssembly字节码中的整数都用LEB128格式表示。

    字节序不是什么

    • 数组在内存中永远是从前向后排的,和字节序无关
    • C/C++中的结构体也永远是从前向后排的,其他高级语言可能会重排对象的内存布局,但这同样和字节序无关
    • 整数或浮点数在CPU寄存器中使用何种字节顺序或位顺序,和字节序无关,实际上这对程序员也不可见,概念上我们都完全可以认为寄存器都是从前向后,类似big-endian般排列
    • 字节序只和数据从内存被加载到寄存器这一过程有关,且单位是字节而不是位

    意义何在

    字节序是一个习惯远比实际好处重要的领域。如果一定要为两种字节序列出一些好处的话:

    • Big-endian更加直观,读取一个4字节整数和4字节数组采用同样的顺序,由于网络通信使用big-endian,因此big-endian的程序在处理网络数据时还能省去字节反转的开销
    • Little-endian环境下,长度不同的数据能够保证低位重合,也就是说32位的0x1234用16位指令去读取依然是0x1234,这节省了类型强制转换时的开销

    但站在今天的角度,这些优缺点都无伤大雅。最重要的原因,不过是x86活下来成了主流,因此little-endian碰巧也成了主流,一如Windows和C语言。计算机领域各个层次都充满了抽象,如果有什么东西能穿越潮起潮落流传至今,原因几乎就是兼容性而已。

  • cJSON源码分析-实现

    好了好了,这篇文章拖了真久啊,活活三个月。不过之前承诺要经常更新博客的,所以先把这个烂尾的坑给填了。

    之前提到了cJSON这个C语言写成的JSON解析库的接口,也就是头文件里的内容。这一次我们来分析一下实现。上车吧。

    还是按照源代码的逻辑走。我们发现cJSON最开始有一个全局的字符指针ep,以及一个用以返回ep的函数。可以看出,这个ep是用来存储错误信息的。这种实现是C语言的常用手法,即把一些状态用static的方式隐藏在单个文件中,并实现一些函数当作接口。

    static const char *ep;
    const char *cJSON_GetErrorPtr(void) {return ep;}

    下面是一个大小写无关的字符串比较函数。看后面的源码可以发现这个函数用在了JSON对象名的查找当中。实现没有什么特别的难点,所以就不提了。顺便说一句,在C++里要实现这个大小写无关比较,可以用STL里的一个方法叫做lexicographical_compare,搭配lambda表达式可以轻松达到目的。

    作者把负责动态内存分配和释放的函数直接「填」成了标准库的malloc和free,像C++的allocator一样。如果担心内存碎片的问题,可以自己再当个二道贩子,实现一个内存池,不过那有点背离本文的主题了。作者自己实现了一个版本的strdup,里面也是用这个用户可以更换的cJSON_malloc进行内存分配的。

    新建JSON和释放JSON的工作不难,前者就是内存分配的问题,后者就判断一下JSON的type,如果是基本类型就直接释放,如果是数组或者对象就递归删除(类似二叉树)。

    目前好戏来了。第一个函数是解析数的。写过词法分析器的就懂,这个用自动机很容易描述,不过这里实在是没有什么好的能在电脑上绘制自动机图的工具,所以用列表表示这个过程,看看应该能体会。

    1. 开头有负号吗?有就记下来,向前走。
    2. 第一个数字是0吗?是就前进,反正默认的结果都是0.
    3. 这是小数点以前的部分,一位一位地循环解析就好了,到第一个非数字的字符为止。
    4. 有点并且点后面有数字吗?有就把小数部分也解析了加上去。
    5. 有E或者e吗?有就算10的幂次方。

    简单吧?我们接着往下走。看到一个…奇怪的函数。

    static int pow2gt (int x)
    {
        --x;
        x|=x>>1;
        x|=x>>2;
        x|=x>>4;
        x|=x>>8;
        x|=x>>16;
        return x+1;
    }

    看见这个函数心里大概会想——什么鬼?名字看不懂,内容也看不懂。唔,不过看在它参数和返回值都是简单的int类型,不妨写个小程序测试一下结果。(限于篇幅省略结果)跑完之后我们猜测,这个函数的目的大概是返回一个不小于x的2的整数次幂。(对2的整数次幂还不敏感吗?)那我们来根据代码验证一下。

    先略过这个减1的过程,看看位运算。我们假设整数的二进制形式从右向左,最后一个值为1的位是第n位,那么运算的过程是:

    1. 首轮,第n位右移1位,经过按位或运算,第n和n-1位(n右边那一位)确保为1.
    2. 第二轮,类似地,n和n-1都右移2位,所以n-3到n都是1.
    3. 第三轮,同样,此时n-7到n位都可以确保为1.
    4. 第四第五轮后,从1到n位都是1了。右移到16截止是因为这里的整数只有32位。

    最后往这n位连续的1上再加个1,就是1后n个0,即2^n了。起来这个过程有点故弄玄虚的意思,因为我们也可以用循环的方式来解决。不过这里作者巧妙利用了整数位数的限制,用五次位运算达成了对任意整数都有效的效果。为什么要减1呢?因为不减1再加回去的话,对一个已经是2^n的数进行运算会得到2^(n+1),不符合我们的预期。实际上,这样的位运算技巧在《高效算法的奥秘》和《深入理解计算机系统》中都有相关的阐述。

    这个函数有什么用呢?搜索一下就会发现。它只用在了一个地方,就是下面这个ensure函数。继续追踪可以发现,这个函数包括下面的update以及printbuffer这个结构体,都是用来存储缓冲区的。在缓冲区里面空间成2倍地扩大。不过我们的重点在字符串解析和内部的数据结构。

    到这里,我们回过头来整理一下思路。JSON的类型有6种,而操作又都有解析和输出两种。

    • null和bool,由于bool只有两种固定的值,所以对于这两者,输出和解析都是简单的strcmp、strcpy就可以。
    • string,解析本身难度不是太大,去掉两端引号中间的就是字符串内容。但是有两个(或者说就是一个)问题,一是要注意反斜杠’\’开头的转义字符,二是字符串涉及到utf16到utf8的转换。输出的话不是什么太大问题。
    • number的解析前面已经说过了,浮点数输出要考虑一下精度,IEEE754标准和utf8都是坑。
    • array和object都是递归解析。如果要按格式输出,缩进是一个问题。

    唔,好尴尬,写到这里突然不知道怎么继续了。在这里贴一下主要的解析函数parse_value的代码。

    /* Parser core - when encountering text, process appropriately. */
    static const char *parse_value(cJSON *item,const char *value)
    {
       if (!value)         return 0;   /* Fail on null. */
       if (!strncmp(value,"null",4))   { item->type=cJSON_NULL;  return value+4; }
       if (!strncmp(value,"false",5))  { item->type=cJSON_False; return value+5; }
       if (!strncmp(value,"true",4))   { item->type=cJSON_True; item-&gt;valueint=1;    return value+4; }
       if (*value=='\"')               { return parse_string(item,value); }
       if (*value=='-' || (*value>='0' && *value<='9')){ return parse_number(item,value); }
       if (*value=='[')                { return parse_array(item,value); }
       if (*value=='{')                { return parse_object(item,value); }
       ep=value;return 0;  /* failure. */
    }

    跟我前面的分类一样,很清楚了。至于空格,这个函数在每次被调用之前都会先调用一次skip函数,用来跳过空白的:

    /* Utility to jump whitespace and cr/lf */
    static const char *skip(const char *in)
    {
        while (in && *in && (unsigned char)*in<=32)
            in++;
        return in;
    }

    查看ASCII码表就可以知道32之前的基本都是不可见的控制字符或者空白,这里的条件判断真是简单粗暴。

    parse_array的过程已经说得比较清楚了,就是不断地跳过空格、读取逗号、再跳过空格、读取一个新的对象的循环……直到遇到反方括号。前面那个指针ep的用途也明白了,就是指向读取失败的地方。parse_object类似,只是每次循环还要插入一个读名字的过程。

    输出部分没有什么特别值得提的地方(其实是懒),要注意的就是输出array和object的时候需要控制一下缩进。总的来说,cJSON的代码逻辑就是这个样子。阅读这样「接地气」的代码,好处在于能够快速学到很多这门语言的最佳实践,但是繁杂的工程细节也会让人厌烦。好在大一些的项目往往在抽象上做得更好,方便我们抽丝剥茧,寻得新知。

  • cJSON源码分析-接口

    最近因为各种忙碌,博客一直没有更新。下半学期的ACM/ICPC报名已经过去了,我没有参加,因为觉得自己对这种比赛没有什么特别的兴趣。其实话说回来,要和人「当面比」的事情,我很多都不喜欢。大概是因为从小到大的不自信导致的。这边Rails的项目快要写完了,收获不少,会找个时候专门用一篇博文记述。喜欢编程,所以就总是停不下来,想找点事情做。大家都说,提升编程能力要做项目。这话不假。但是编程就像写作文,初中生洋洋洒洒写个小说,很可能只会被成年人看作是幼稚文章。编程作为一门手艺,阅读他人的源码也是很重要的。感谢自由软件运动让我们有大量的源码可以用来学习,也感谢GitHub这样的平台能让我们更加方便地获取和发布源代码。所以最近可能会陆陆续续地更新一些程序的分析,也借这个过程提高一下自己细粒度层面的编程水平。希望不会烂尾。(flag已立!)

    要分析呢,就从最熟悉的语言开始吧,也就是C咯。然后找一个简单的开源项目。好啦,中央已经决定了,就是cJSON了。同济2014级软件学院的C语言期末作业就是要求写一个cJSON解析器,后来我才发现那个提供的头文件就是cJSON里的……

    软件的第一要义就是,它创造出来必须有用。那cJSON,顾名思义,就是一个程序,它能够:

    1. 将文本化的JSON转化为C语言可以操作的数据结构(解析)
    2. 将内存里的数据结构输出为格式化的JSON字符串(序列化)

    具体JSON是什么格式,不用我再多说了吧。在JavaScript流行的今天,JSON由于其简单的语法和支持嵌套的特点,得到了广泛应用。许多框架的配置文件什么的,都是用的JSON格式。也有许多人试图用JSON逐渐取代XML语言。

    我们先来看源码的文件结构:

    • cJSON.c
    • cJSON.h
    • test.c
    • README.md
    • LICENSE
    • CMakeLists.txt
    • Makefile
    • cJSON_Utils.h
    • cJSON_Utils.c
    • test_utils.c
    • test/

    其中末尾带util的都是针对JSON做的一些扩展(RFC6901和RFC6902),我们先略去不谈。其实作为库,核心部分就两个文件,一个cJSON.h声明,一个cJSON.c实现。那么我们就先来看看头文件,cJSON到底提供了哪些接口。

    /* cJSON Types: */
    #define cJSON_False  (1 << 0)
    #define cJSON_True   (1 << 1)
    #define cJSON_NULL   (1 << 2)
    #define cJSON_Number (1 << 3)
    #define cJSON_String (1 << 4)
    #define cJSON_Array  (1 << 5)
    #define cJSON_Object (1 << 6)
    
    #define cJSON_IsReference 256
    #define cJSON_StringIsConst 512
    
    /* The cJSON structure: */
    typedef struct cJSON {
        struct cJSON *next,*prev;   /* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
        struct cJSON *child;        /* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
    
        int type;                   /* The type of the item, as above. */
    
        char *valuestring;          /* The item's string, if type==cJSON_String */
        int valueint;               /* The item's number, if type==cJSON_Number */
        double valuedouble;         /* The item's number, if type==cJSON_Number */
    
        char *string;               /* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
    } cJSON;
    
    typedef struct cJSON_Hooks {
          void *(*malloc_fn)(size_t sz);
          void (*free_fn)(void *ptr);
    } cJSON_Hooks;

    整个分成三个部分,一个是标记Type的宏,包括了cJSON结构体里type成员的所有取值。它在这里额外增加了IsReference和StringIsConst两个类型标记。我们注意到作者表示cJSON的类型不是用的正常的自然数的顺序排布,而是利用位移运算构成了等比数列。为什么要这样呢?因为这样的话一个类型就可以和IsReference和StringIsConst叠加了。这是C语言里的常用技巧。

    再往下,我们可以看到作者定义了一个叫做cJSON_Hooks的结构,包含了malloc_fn和free_fn两个函数指针作为成员。很容易看出来这两个函数指针的原型也刚好对应malloc和free的函数原型。虽然还没有开始阅读源代码,不过我们自信地猜想,这个结构的作用类似于C++ STL中的allocator,负责标准分配之外的分配方式。话说我一开始写maolang的容器的时候也用过这个方法,但是后来觉得太累赘而放弃了。

    再看下面的代码。

    /* Supply malloc, realloc and free functions to cJSON */
    extern void cJSON_InitHooks(cJSON_Hooks* hooks);
    
    /* Supply a block of JSON, and this returns a cJSON object you can interrogate. Call cJSON_Delete when finished. */
    extern cJSON *cJSON_Parse(const char *value);
    /* Render a cJSON entity to text for transfer/storage. Free the char* when finished. */
    extern char  *cJSON_Print(cJSON *item);
    /* ... */
    
    /* Returns the number of items in an array (or object). */
    extern int    cJSON_GetArraySize(cJSON *array);
    /* ... */
    /* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
    extern const char *cJSON_GetErrorPtr(void);

    限于篇幅,把更多的函数声明省略了。函数声明前面加上extern关键字是可选的,仅仅是标注它是一个外部链接的函数而已。然后是一堆用以创建、删除、插入、修改JSON结构的函数,更详细的内容在头文件里。

    /* Duplicate a cJSON item */
    extern cJSON *cJSON_Duplicate(cJSON *item,int recurse);
    /* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
    need to be released. With recurse!=0, it will duplicate any children connected to the item.
    The item->next and ->prev pointers are always zero on return from Duplicate. */

    复制的函数有一个额外参数,表示是否选择递归复制(深拷贝)。这里还有一些函数,我们分析实现的时候再说。在头文件的最后还有一个有趣的宏:

    /* Macro for iterating over an array */
    #define cJSON_ArrayForEach(pos, head) \
        for(pos = (head)->child; pos != NULL; pos = pos->next)

    虽然仅仅是简陋的宏替换,不过还真是搞出了现代语言的感觉呢。

    看完头文件之后,我们发现,cJSON这个简单的解析器,名堂却不小,提供了不少实用的接口。至于这些接口内部实现的细节,我们下一篇文章再来讨论啦。

  • C语言里的void类型

    今天去图书馆坐了坐,看罢《Essential C++》,觉得过分基础,实在没什么意思。碰巧包里还有一本神作《C标准库》,详述了实现ANSI C标准库的所有过程。第一章讲的便是assert.h的实现。这个宏本身没有什么难度,无非是在一个函数的基础上包装一下,说不定GCC或者Clang直接把这个函数做成builtin了。不过不管是书上还是musl库的代码,都有一个让我注意到的地方:

    #ifdef NDEBUG
    #define    assert(x) (void)0
    #else
    #define assert(x) ((void)((x) || (__assert_fail(#x, __FILE__, __LINE__, __func__),0)))
    #endif

    这里的这个__assert_fail就如同我上文所说,是一个用于输出内容的函数,作用是输出错误信息然后调用abort函数退出。

    关键在于这里有个奇怪的(void)0表达式。首先我们可以判定表达式的类型是void,对吧?不过这个void类型的表达式有什么意义呢?我们学习C语言的教材对这一点基本都语焉不详。我尝试了一下,把(void)0赋给一个int类型的变量,编译器是这样给我抱怨的:

    test.c:3:9: error: initializing 'int' with an expression of incompatible type 'void'
      int i = (void)0;
          ^   ~~~~~~~

    反正意思就是无法把这个类型转换成匹配的int啦。

    那我们转念一想,尝试用void来定义一个变量呢?得到这样的错误提示:

    test.c:4:10: error: variable has incomplete type 'void'
      void j;
           ^

    等等!incomplete type?哪里见过这个?对啦!如果在一个结构体内部定义一个以这个结构体为类型的对象,编译器就会抱这个错误,提示类型还没有定义完。所以写链表或者二叉树的时候,里面存储的其实是「指向节点的指针」。

    继续带着疑惑,我查询了C11的标准草案(正式版是收费的,不过两者在这些基础问题上相差无几),其中有三处提到了我想知道的「void类型」:

    …The void type comprises an empty set of values; it is an incomplete object type that cannot be completed…

    …An lvalue is an expression (with an object type other than void) that potentially designates an object…

    The (nonexistent) value of a void expression (an expression that has type void) shall not be used in any way, and implicit or explicit conversions (except to void) shall not be applied to such an expression. If an expression of any other type is evaluated as a void expression, its value or designator is discarded. (A void expression is evaluated for its side effects.)

    而《C程序设计语言》里这样描述:

    void对象的(不存在的)值不能够以任何方式使用,也不能被显式或隐式转换为任一非空类型。因为空(void)表达式表示一个不存在的值,这样的表达式只可以用在不需要值的地方,例如作为一个表达式语句(参见A.9.2节)或作为逗号运算符的左操作数(参见A.7.18节)。

    可以通过强制类型转换将表达式转换为void类型。例如,在表达式语句中,一个空的强制类型转换将丢掉函数调用的返回值。

    这下终于明白了!那么稍微总结一下:

    1. 在C语言中,void可以作为一个合法表达式的类型,亦即它在语法结构里可以作为一个表达式
    2. void类型的表达式不能转换为其他任何类型的表达式,也就没有了赋值的可能
    3. 编译器会特殊看待void类型,将其作为一个「未被定义完整」的类型,也就没有了定义变量的可能
    4. 尽管void未被定义完整,但是如同其他结构体一样,我们是可以正常使用void*的,并且直接对一个void*解引用,结果是void类型的表达式
    5. void类型和其他类型不相容,但是该有的表达式副作用还是会有

    可以说这里面的逻辑是非常自洽且合理的。所以不得不佩服设计C语言和C++的人,这些概念就像物理定律,看上去复杂,但是用这套逻辑推导下去很多看似不同的东西都可以得到统一解释。

    现在回头看assert宏的实现代码,不难理解啦。因为__assert_fail函数的返回值类型是void,但是它需要被用在一个逻辑表达式里。于是它巧妙地结合了逗号运算符,配合短路求值的规定实现了assert需要的效果。至于把最后的表达式类型也转换为void,是为了不让它作为值被赋给变量。而在定义了NDEBUG的状态下,用(void)0占位也比什么也不写来得好,编译器会提示类型不相容,而直接替换成空的话,在代码复杂的地方错误类型也许会莫名其妙。

  • C语言要考试了,你应该问问自己什么?

    呐,这次我又来装逼了。我保证,这是这学期最后一次。

    如果从我在百度C++吧的第一次发言(2009年7月2日)到今天,日子已经过去了2380天,然而我之前还没有考过一次C语言。好慌……好了好了,打住,不然待会儿真被当成装逼了。不过我说我初学的时候用while包住过main你信?至少你没有这样干过吧。

    那么,我尽力,把自己带入2380个日子前,努力以一个初学者的视角来想一下C语言到底包含着什么知识点。我承认这种方法充满了应试色彩,而且对于上机写代码这种形式的考试可能并没有十分显著的作用。不过我是真的希望每个看到这篇文章的读者,即使以后不靠C语言吃饭,写不出优秀的C代码,也至少能对C语言有一个正确的理解。那我即使是背上装逼的骂名,也心满意足了。(其实我会告诉你我是因为不想复习物理了吗)

    关于新手指引之类的内容,我都写在了那个「C指引」的文档里,点击打开它的GitHub页面。外加精力有限,因此不想再重复那些内容。所以我会列一些问题,并且不会给出答案。希望能迫使你思考。这些问题可能会带有我自己的主观色彩,不喜欢就点关闭咯。

    先从初级开始。

    1. C语言代码文件的后缀名是什么?这类文件可以用哪些工具打开和浏览?
    2. 什么是可执行文件?它需要另外的软件打开它吗?
    3. C语言文件从源代码到可执行文件的过程叫做什么?这个过程中发生过什么?
    4. 如果我要输出Hello, world!,我需要在程序的第一行写上什么?如果不写有什么后果?
    5. 为什么程序执行时候的窗口会一闪而过?那个黑色的窗口究竟是什么?
    6. main是什么?我把代码写在main外面会有什么后果?
    7. main前面应该是void还是int?
    8. 变量的存在有什么意义?C语言里的变量有哪些类型?
    9. 我怎么去获取来自用户在命令行的输入?接受输入的函数跟输出的函数在调用方式上有什么区别?
    10. 既然一个字符串在代码里不能跨很多行书写,那么我怎么在字符串里表示换行?制表符呢?
    11. while语句和do…while语句的区别在哪里?
    12. switch…case语句里的break有什么作用?它和if语句比有什么局限性?
    13. 对于if语句括号里的内容来说,一个等号和两个等号有什么差别?两个等号中间能有空格吗?
    14. 如果变量a是double类型,那a=5/2之后a的值应该是多少?

    什么,觉得太简单了是吧?那来中级的问题:

    1. 如下的代码会按何种方式执行?会输出什么结果?
    int sample = 1, ok = 0;
    if (sample == 1)
        if (ok)
            puts("Ok.\n");
    else
        puts("No.\n");
    1. goto语句是如何使用的?为什么我们提倡不使用goto语句?
    2. 什么时候程序需要函数声明?函数声明应该放在哪里?
    3. 到底什么是EOF?EOF可以用在我们输入的什么地方?
    4. i++和++i有什么不同?i+++++i这种表达式有意义吗?
    5. 应该如何安全地读取一个文件里的内容?写文件呢?
    6. 说出数组和指针的区别(至少三个)
    7. 字符类型有数值吗?strcmp的返回值有什么含义?
    8. static和extern关键字有什么用?

    啊哈,还是不满足吗?我们来看看高级篇:

    1. 为什么在main函数里定义一个非常大的数组,程序可能会崩掉,而我放到外面就不会了?
    2. char *const和const char*的区别在哪里?对于声明const char *s=”abcd”,这个指针s到底「指向」什么地方?
    3. 如果我像这样定义:
    typedef char* sptr;
    const sptr b;
    这里的b到底是什么类型?是const char*吗?
    1. 考虑这样一个结构体:
    struct sample {
        int  a;
        char b;
    };

    sizeof(struct sample)的值会是多少?为什么?

  • 记一次失败的装逼经历

    呐,文如标题,这次要说的是一次十分失败的装逼经历,或者说,一场惨痛的人生教训。有那么严重么?至少,对于新年的第一天和第二天的我来说,是这样的。

    讲道理,在我看来,其实装逼本身就可以说是失败的。真正的强者不需要装逼,他们的强势永远都是一种自然的流露。当然,在弱者,或者说特定圈子之外的人,可能也会把这样的行为当成是装逼。就像,知乎上的很多习惯和流露的价值观,对于知乎社区的活跃用户来说,是无比正常的事情。但是对于一些微博或者贴吧上的网友而言,这些思维方式和语言习惯必是装逼无疑。

    那我为什么还要说自己是装逼呢?

    废话,因为我失败了啊。

    事情是这样的,我再叙述一遍。软件学院大一上的C期末作业要求我们写一个解释器,要解释的语言被称作Mao,包含int和double两类变量的声明、表达式求值和print变量。其实一开始接到这个项目我是很高兴的。因为比起去年的JSON Parser来说,这个项目可以扩展的地方太多了。语言确实没有办法描述我的心情。那是一个星期二,下午无课,我跑到图书馆看了一个下午的编译原理。

    「我要做一门真正的编程语言。」

    从那天开始,直到2015年结束,可以说,我的脑子里从来没有忘过这事,偶尔梦里也梦到过。以至于我11月去无锡给我朋友过生日的时候,在路上因为突然想通了Mao语言的上下文无关文法,而突然哈哈大笑起来。

    然后就是紧锣密鼓的设计和写代码。其实我不是一个执行力很强的人,很多想法都停留在脑海里要拖延很久才会去实现。这带来了很多问题。递归下降的语法解析器我从十二月下旬才开始写,由于是第一次写,代码删删改改,自己觉得满意了,结果对了(我当时是怎么判断出结果对的呢?)就停下来了。好,停下来之后又开始写对象系统,整个活生生朝着PHP的方向做。直到2015年的最后一天,我把对象的运算做好了之后,输入1+1+1测试,然后……

    是的,我就是这么后知后觉。

    我写的递归下降语法分析器,在解析表达式的时候,因为要考虑优先级,所以把包含相同优先级的运算式,划到上下文无关文法中同一类的非终结符里。举个例子,就像很多编译原理教材都会讲的一样,两个表达式加减的结果叫做term,两个表达式乘除的结果叫做factor,一个term可以这样定义:

    term = term op-1 factor
         | factor
    
    op-1 = '+'
         | '-'

    在理论上,这是一个十分完美的结构,从这样的语法结构构造出的表达式树,也可以按照正确的执行顺序进行计算。比方说1-1-1,先算左边的1-1得0,再做0-1,最后得结果是-1. 因此第二个减号是树根,第一个减号是左子树的树根。这是左结合性的运算符。那么对于右结合性的运算符,比如乘方符号^,或者说赋值号=,这些是先算右边的,那么我们修改一下语法即可:

    assignment = identifier assign-op assignment
               | expr
    
    power = expr exponent-op power
          | expr
    
    assign-op = '='
    
    exponent-op = '^'

    当我们计算表达式2^3^4的时候,顺序并不是跟加减乘除一样的,而是先算右边的3^4,得81,然后整个表达式的值是2^81,结果就不说了,因为写不下。

    但是这样做有一个很大的缺陷,那就是我们的LL(k)解析器无法解析左递归的情况,会陷入无限循环。什么是左递归呢?就是上文提到的描绘左结合性运算符的文法形式。一个term是一个term加或减一个factor,但是这个term到底到哪里为止呢?我们的递归下降解析器,至少是理论上,做不到的。所以从文法上,我们需要移除左递归。当然,如果解析器是从右向左读的,那么也处理不了右递归。

    移除左递归的过程可以说是范式化的,引入一个新的非终结符,修改一下规则,像这样:

    term = factor term-rest
    
    term-rest = null
              | op-1 term

    这个看上去也很好啊,能够解决我们的问题。然而两者合在一起,问题出现了——移除左递归之后,程序解析到的树,结合性出现了问题!而事实上,我写得更蠢:

    term = factor op-1 factor
         | factor

    这样的递归下降解析器根本无法解析1+1+1这样的式子!它到第二个1处就停下来了!而当我发现这个问题的时候,时间已然是2015年的最后一个晚上了。

    没有办法了,第二天早上起来试图改进,发现越改问题越过。中午决定放弃,推倒表达式解析重来。其他像for、while等写好的语法也暂时搁置下来。撸了一个下午到晚上,在1月2日前完成了作业。

    然而好景不长,第二天晚上当我写好文档准备交的时候,又发现bug了!我的程序不能正确处理表达式开头的负号。简单说就是,我的程序会自动“分划”输入的表达式。为了解决负号的问题,程序会判断当前表达式开头是不是有负号,如果有的话,就整个表达式取负。出发点是对的,但是后面一遇到加减或者各种叠加的括号,问题就来了,比如会把-1+2的结果当成-3.

    剩下二十分钟,真没办法了,先交吧。

    然后我改到了两点。

    讲道理,我感觉自己真的没有资格参加答辩。

    就是这样。

  • 关于命令行参数的那些事

    事情是这样的。这学期的C语言课程期末的大作业(说实话我不太愿意称其为“项目”……),就像我的前几篇博文里提到的,要求编写一个程序,可以读入一串字符,包含声明变量、求值、打印的功能。如何进行词法分析和构建表达式树的过程在前两篇文章我已经说过了,这里不再赘述。那么很多同学问起来的一个问题是,在作业的要求里面,有一项是“从第一个命令行参数读入文件名”——这个到底是什么意思?

    有些同学把它理解成了从标准输入获取一个文件名的字符串。大错特错啦。不过也难怪,我们这个年代的人小时候接触DOS的机会不多了,可能从小到大根本就没有在计算机上敲过命令,以至于第一次写C程序的时候看到那个黑漆漆的窗口还大失所望。

    不过现在好了,至少你晓得了电脑上某一个“角落”存在着这样一个“黑框”,你可以在上面输入东西。在Windows系统上,我们可以通过开始-运行(没有开始菜单的话就按Win+R键),弹出“运行”窗口,输入cmd然后回车。这样我们就打开了一个“命令提示符”窗口。(什么,你用的是Linux?用Linux还不知道命令参数是什么,我劝你还是卸了吧)

    这个窗口在Windows NT系统里可以作为对DOS界面的一种模拟,因为不是所有的Windows应用程序都有图形界面的,比如我们现在写的C程序就没有,所谓的标准输入和输出只能在cmd模拟器里面跑。(如果你对命令行窗口仍有疑惑,可以参考我在知乎上的这个回答)在黑窗下,我们可以尝试敲敲一些命令:

    • dir,列出当前所在目录下的文件
    • cd,进入某个目录,cd ..表示回到上一层目录
    • 盘符,进入某个分区,比如输入D:回车表示进入D盘
    • exit,退出命令提示符
    • del,删除某个文件(注意可没有回收站让你反悔)
    • 把文件拖动到命令窗口,命令会自动显示这个文件的路径,直接输入文件名回车系统会调用适合的程序打开它

    好了,基本的操作我们已经熟悉了。这样已经够了。对啊,比如说我们用del命令的时候,del a.txt,这里的a.txt就是del命令的命令行参数!在Windows下,我们的程序最后会编译成某个.exe文件,IDE执行它是自动启动的,不会给我们输入命令行参数的机会(讲道理的话是可以设置的)。假设我们编译成某个可执行程序,放在当前所在的目录下,比如说,叫mao.exe吧,然后源文件名叫做mao.mao,我们这样输入:

    mao.exe mao.mao

    系统会给mao.exe应用程序的main函数传入argv参数。不过前提是你要写对main函数的原型,像这样:

    int main(int argc, char *argv[])
    {
        /* 代码在这里 */
        return 0;
    }

    那么对于上面的例子,我们的两个参数的值应该是这样的:argc=2,argv[0]="mao.exe",argv[1]="mao.mao".(唔也有可能是完整路径,懒得测了)然后我们打开文件的话,像这样打开就行了:

    #include <stdio.h>
    
    int main(int argc, char *argv[])
    {
        if (argc &gt; 1) {
            FILE *fp;
            if ((fp = fopen(argv[1], "r")) == NULL) {
                printf("Open file failed.\n");
            } else {
                /* 打开之后的操作在这里 */
                fclose(fp);
                /* 不要忘记了关闭 */
            }
        } else {
            printf("Not enough parameters!\n");
        }
        return 0;
    }

    当然我的代码仅仅只作为一个参考,抄去说不定程序会崩哦。

    好了,如何操作我们就先说到这里。你可能会纳闷——为什么要弄得这么麻烦?

    • 普通回答:为了让同学们熟悉一下文件操作和命令行参数的知识,提高自己查找知识解决问题的能力
    • 二逼回答:因为助教用的是Linux,这样做会很方便
    • 文艺回答:每一个C程序员都要对Unix系系统有亲近感,这是培养亲近感的很好的方式

    好吧,今天就说到这里,如果你们对Linux有兴趣,下次可以专门聊聊。

  • 构建Mao语言运行器-语法树

    好了,前面我们说到了词法分析的过程。如果你按照我的思路进行的话,现在你已经可以把源代码转换成一个一个的词法单元,也就是所谓的token了。接下来要做的就是按照我们人为设定好的语法规则把这些词法单元组织起来。我们前面说过,Mao语言的语法结构极其简单,只有声明变量、表达式和print输出三个部分。而表达式所包含的内容也只包括加减乘除和括号。更关键的是它是保证输入的代码不会出错的。(当然你要满足程序对于健壮性的要求那就是另一回事了)

    首先从变量的声明开始讨论起。Mao语言里只有int和double两种类型。所以我们可以一句一句地读取,直至分号结束。

    struct token tkindex;
    while (!istokenend(tkindex = gettoken())) {
        /* 你要记住这是伪代码,不要问我这个函数怎么来的 */
        if (tkindex.type == TOKEN_INT) {
            while (!istokenend(tkindex = gettoken())) {
                if (tkindex.type == TOKEN_VARIABLE) {
                    /* 把这个名字和类型加到变量列表里 */
                } else if (tkindex.type == TOKEN_COMMA) {
                    /* 如果是逗号,继续前进 */
                    continue;
                } else if (tkindex.type == TOKEN_SEMICOLON) {
                    /* 遇见分号,声明语句结束,循环停止 */
                    break;
                }
            }
        } else if (tkindex.type == TOKEN_DOUBLE) {
            /* 跟上面一样玩 */
        } else if (/* 其他类型 */) {
            /* 其他的处理代码 */
        }
    }

    为什么可以写得这么简单?因为变量声明的时候保证没有带等号的初始化语句哦。

    变量定义还是很简单的事情,重头戏在于表达式的求值。就算是后面的print语句,你不能保证它不会给你来个print(a+b)或者直接print(1*3/2),所以要先把表达式求值做好才能实现输出语句。(注:有同学指出需求文件里只说了print不会输出表达式,只会输出变量,这样就更容易咯)

    如何给表达式求值呢?参考文档里给的暗示方法,是把中缀表达式转换为后缀表达式(逆波兰表示法),其中要用到所谓的调度场算法。然后利用栈来求解。这个方法不难,不过我们这里为了程序的可扩展性和方便理解,决定采用树的方式来实现。并且这里的树还是最简单的一种情况,即二叉树。如图:

    语法树例子

    所以,你以为我会这样老老实实写个结构:

    struct expr_node {
        int type;
        char * var_name;
        union {
            int ival;
            double dval;
        };
        struct expr_node * left_child;
        struct expr_node * right_child;
    };

    哼哼,想错了。在这个话题上,为了说明问题,我们用不着那么麻烦。

    我在软件工程学生如何学好C语言?这个问题的回答里面说过,理解数据结构不要过分机械地非要做一堆结构体和指针来模拟。计算机科学的核心是抽象。把握好抽象然后再来特化到适合特定场合的结构。所以呢,还是用数组吧。

    什么?数组不是顺序存储的结构吗?naive,我们可以这样定义:

    • 数组的第一个元素,可以看作整个树的头
    • 对于任意第n个元素,它的左子节点是第2n个元素,右子节点是第2n+1个元素
    • 类似地,对于任意第n(不论奇偶)个元素,它的父节点编号是n/2

    对于这棵树,我们可以稍微分析一下。这棵二叉树每一“层”包含的元素数量是2^n个。所以我们给这个等比数列求一个和,结果就是2^n – 1次方。也就是说对于一个10层的二叉树,我们需要1023个空间的数组。其实也不是很大,而且我们也不太可能出现一个需要10层的表达式数……好吧,如果你实在不放心,C99标准支持以变量初始化数组,自己先感受一下再开数组吧。

    好了,数据结构准备好了,我们可以开始构建这个树了。讲道理说语法树有点言过其实,我们这里构建树的过程连什么LL(1)都用不到。我们来思考一下,假如我是一台计算机,我要怎么计算公式k * (a+c) + (j=3)呢?

    你也许会说,我们从k开始,k右边的第一个符号就作为树根。但是加减乘除是有优先级的,最后算的应该是加号。注意,我们给这个表达式树求值是自上而下递归求值的,我们把下层计算好之后再把结果返回给上层来计算。所以我们应该以哪个符号开始呢?嗯,第一个优先级最低的符号。在这里,我们的最低优先级的符号是等号,其次是加减,最后是乘除。

    等等,貌似有点不对。对于上面的那个等式,如果我们要搜到第一个优先级最低的符号也就是等号,那逻辑不对啊。嗯,因为上面我们没有考虑到括号。所以修正一下上面的规则——第一个括号之外的优先级最低的符号。你需要定义一个变量来记录当前是否外面还有括号。唔,是“第一个”吗?我想应该是最后一个吧,因为在你搜索到最后一个优先级最低的符号之前,是不知道当前位置上的符号是不是最后一个的。像a=1+1这种当然好判断,有等号就行了,可是比如a*21/b+11我怎么知道后面还有没有更低的呢?所以,应该改成——最后一个(括号之外的(优先级最低的(符号)))。(打括号是为了好断句)

    好了好了,我们已经把“寻找树根”的任务搞定了。任务已经完成了一半了。什么,你不相信?我们构造这个树是递归的喂!我们只要找到树根,然后正确分划,这个树就搞定了。这就是递归的力量。So,我们再来讨论分划的问题。其实分划挺简单的,我们已经找到了那个“树根”的token,然后parse它左边的部分和右边的部分。

    空话说得太多没用,我们来讨论下具体的实现。根据上文的思考,我们可以总结出从token流产生语法树的若干规则:

    • 如果整个串中只有一个token,是数或者变量,那么这个表达式树的值就是这个数或者变量的值
    • 否则,当前表达式树的值是左子树的值同右子树的值经过计算的结果,计算的方式由树根类型决定
    • 树根定义为当前token流中不在任何括号内的最后一个优先级最低的符号,左子树是从token流开始一直到树根的所有内容,右子树同理
    • 如果有括号,把括号内内容当作一个整体

    由于叙述需要严谨性,所以听起来很复杂。其实整个的实现过程非常的清晰明了,我们来举个例子说明:

    对于表达式foo = sample * (3 + 5)而言,我们的搜索过程是这样:

    1. 从左至右找到最后一个不在括号内的最低优先级符号“=”,将其作为树根。
    2. 树根左边的子token流里只有一个变量foo,所以左子树的值就是foo,停止。
    3. 树根右边最后一个不在任何括号内优先级最低的运算符是“*”,将其作为子树的树根。
    4. 子树树根的左边的token流也只有一个内容,就是sample变量,所以同样停止搜索。
    5. “*”右边的括号是一个整体,我们在括号内部搜索不在任何“括号”(打引号是因为这里的“不在”是指不在这个括号内部的另外的括号里)内的最低优先级运算符,找到了,是“+”。
    6. “+”左右都各是一个数,所以这两个子树停止搜索。

    如果把这棵二叉树表达为一个数组,我们的数组结构是这样(编号表示下标,C语言的话要全部减1):

    1. 符号 =
    2. 变量 foo
    3. 符号 *
    4. (空)
    5. (空)
    6. 变量 sample
    7. 符号 +
    8. (空)
    9. (空)
    10. (空)
    11. (空)
    12. (空)
    13. (空)
    14. 数值 3
    15. 数值 5

    之所以会有那么多空位是因为数或变量是没有子节点的,而数组的话必须要把它们的空间留足。好了,我们解决了理论上生成树的问题,如何用代码来实现呢?首先要面对的一个问题是,不管我们如何递归处理这个表达式,它始终都是一个整体,比如上面,当我们在创建符号“*”的左右子树的时候,为什么不会搜索到“=”那里去呢?所以我们需要两个变量,来标记我们可以搜索的token范围。如果你觉得我说的话很晦涩的话,那是因为我没说清楚。想想子字符串吧,一样的道理。

    struct expr_node tmp_tree[128];
    struct token stream[1000];
    /* stream数组假设已经经过了词法分析阶段的输入 */
    /* 为了说明问题,我才使用的全局变量 */
    void create_tree(size_t tkstart, size_t tkend, size_t tree_root)
    {
        if (tkstart == tkend) {
        /* 如果整个流只有一个内容 */
            if (stream[tkstart].type == TOKEN_INTNUM) {
                tmp_tree[tree_root].type = TOKEN_INTNUM;
                tmp_tree[tree_root].var_name = NULL;
                tmp_tree[tree_root].ival = atoi(stream[tkstart]);
                /* 结构体中的匿名联合体是C11标准支持的新语法,可以直接访问它的内部成员 */
                /* 这里我们假设在词法分析阶段对于数保存的是它们的字符串形式 */
            } else if (stream[tkstart].type == TOKEN_DOUBLENUM) {
                /* 同样处理double的情况 */
            } else if (stream[tkstart].type == TOKEN_VARIABLE) {
                /* 变量的情况也是类似,只不过需要保存它们的名字 */
            }
        }
    
        int parentheses = 0;
        size_t lowest_token = tkstart;
        /* 在没有出问题的情况下,第一个token不可能是运算符,所以可以专门规定非运算符的运算符优先级最高 */
    
        for (size_t i = tkstart; i &lt;= tkend; ++i) {
            if (is_operator(stream[i])) {
                if (priority(stream[i]) &lt; priority(stream[lowest_token] &&
                    !parentheses) {
                    /* is_operator和priority都是自己定义的宏 */
                    lowest_token = i;
                }
            } else if (stream[i].type == TOKEN_LPAREN) {
                /* 左括弧,标记变量加一 */
                ++parentheses;
            } else if (stream[i].type == TOKEN_RPAREN) {
                --parentheses;
            }
        }
    
        create_tree(tkstart, lowest_token - 1, tree_root * 2);
        create_tree(lowest_token + 1, tkend, tree_root * 2 + 1);
    }

    这段构造树的代码原理清晰,但是其实它是有问题的:

    • 如果一个表达式整个被括号包住,那么它是无法处理的,因为自始至终变量parentheses不为0
    • Mao语言的文档里有写y=(c+6)*-(1+1)这样的表达式,而我们的程序无法处理两个符号连在一起的情况

    解决第一个问题,可以用一个简单粗暴但是很脏的办法。所有带括号的表达式,最后都会递归到一个括号从前往后包住的形式。所以如果我们发现第一个token是左括号,我们识别一下是不是从头到尾都被包住了,如果是,就再去掉括号往下识别。

    第二个问题,要解决需要我们在识别lowest_token的时候修改一下算法。所谓’+’和’-‘,如果前面有其他非括号的符号,那必然是正负号,否则是加减号。所以,如果发现加减的上一个有运算符,那就把正负号连上作为子树。然后,如果开头是加减号,那就自动补0.

    计算的话,很简单的。用一个switch…case语句分开判断当前是什么符号,default表示是数或变量。类型转换的事情交给编译器就好,你要负责的只是判断这个变量应该是什么类型。什么,一个函数不能返回多种类型的值?那可以返回struct嘛,哈哈哈……

  • 构建Mao语言运行器-词法分析

    这个世界的本质是无序的。所以编程最广泛也是最常被讨论的用途就是处理在计算机看来什么意义也没有的字符串。而处理编程语言的源代码在其中是很特殊的一种,因为编程语言包含的信息按照一定的规则聚合,解析起来比自然语言容易得多。而处理编程语言的源代码的时候该干什么,取决于软件的目的以及这门语言本身的特性。

    好了,言归正传,说说这次的期末作业Mao语言吧。一开始很多同学估计还没反应过来是怎么一回事。以为做一个解释器只要用宏替换就可以了。另外一些人估计连解释器编译器的区别都没有分清楚。讲道理的话,大一的期末项目不太可能要求学生做编译器的,那么多数据结构都没学,汇编也不懂,没法弄。

    那么什么是解释器呢?

    解释器是能够执行用其他计算机语言编写的程序的系统软件,它是一种翻译程序。 它的执行方式是一边翻译一边执行,因此其执行效率一般偏低,但是解释器的实现较为简单,而且编写源程序的高级语言可以使用更加灵活和富于表现力的语法。

    懂了没?一边翻译一边执行。读入输入然后处理的作业在之前也做过不少,所以有人想到一个一个“单词”地做。关键是,我们处理这些源代码的基本单位是什么?先来看看范例里的一段代码。

    int a;
    double x, y;
    int b, c, d;
    a=5.5;
    x=y=(1+a)*6.44;
    a+4;
    a=a/2;
    print(a);
    y=(c+6)*-(1+1);
    print(y);
    d=a/(2/5);
    print(d);

    这段代码基本上涵盖了Mao语言所有能用到的符号。仔细观察可以发现,关键字只有int和double,如果把print写死进去那就是三个。程序中可以出现任意的空格,因此我们需要把这些空格要么去掉要么屏蔽掉。变量名可以是一个字母也可以是很多个字母和数字的组合,当作一个char来弄显然也不行。我们需要一个专门的过程来将源代码里的“多个字符”映射到一个单位里。我们理想的结果应该是生成一个更有结构的数据流,其中一个符号(int、变量名或者是运算符)是一个单位,没有空格的存在。这样的话,很多东西就方便很多了。

    这个扫描源代码生成结构化数据流的过程就叫做词法分析。大多数编译器或者解释器接收到源代码做的第一件事就是词法分析。词法分析生成的东西叫做token,记号还是符文还是什么,随便翻译。显然,我们要做词法分析,就要把整个源代码字符串遍历一遍,并且扫描完之后就可以把它丢掉了,之后的处理是针对这个数据流而不是纯文本进行的。在整个编译或者解释执行的过程中,对源代码或者它对应的信息我们需要过不止一趟 (pass)。多趟的处理也许对效率有影响,但是实现更加简单。所谓one-pass的编译器,很多时候只是作者的炫技之举。当然,对语法极其简单的Mao语言,这不是不能做,只是写得太杂乱就会失去很多乐趣。

    好了,如何定义我们的数据结构呢?比如:

    struct token {
        int type;
        char * content;
    };

    这是最简单的形式。content表示什么自不必说,可以指变量名或者整数浮点数。而更多的固定形式的符号,我们直接用type表示,然后把content设为NULL即可。

    #define TOKEN_INT       1
    #define TOKEN_DOUBLE    2
    #define TOKEN_PRINT     3
    #define TOKEN_VARIABLE  4
    #define TOKEN_INTNUM    5
    #define TOKEN_DOUBLENUM 6
    #define TOKEN_COMMA     7
    #define TOKEN_SEMICOLON 8
    #define TOKEN_ADD       9
    #define TOKEN_SUB       10
    #define TOKEN_MUL       11
    #define TOKEN_DIV       12
    #define TOKEN_ASSIGN    13
    #define TOKEN_LPAREN    14
    #define TOKEN_RPAREN    15

    整个Mao语言的符号不超过这区区十五种。规定好了token的结构和type的取值,我们就可以开始扫描的过程了。

    开始这个扫描的过程应该不算是什么难事。我们从输入的第一个字符开始判断,就像《C程序设计语言》里常用的那样:

    char ch;
    while ((ch = getchar()) != EOF) {
        /*
         * 对输入字符的类型进行判断
         * 如果是一个字符,我们进入处理字母集合(类型、print或变量名)的函数
         * 如果是加减乘除等符号,我们进入处理运算符的函数
         * 如果是数字或者点号,我们进入处理整数或浮点数的函数
         */
    }

    那么对此,我们可以声明这样的一堆函数:

    void type_var(char ch);
    void number(char ch);
    void symbol(char ch);

    这些函数内部是如何运作的呢?举个例子:

    void type_var(char ch)
    {
        char tmp[20];
        size_t index = 0;
        do {
            tmp[index++] = ch;
        } while ((ch = getchar()) != EOF && isalnum(ch));
        tmp[index++] = '\0';
        if (!strcmp(tmp, "int")) {
            /* 将当前流指向的token设置为TOKEN_INT */
        } else if (!strcmp(tmp, "double") {
            /* 将当前流指向的token设置为TOKEN_DOUBLE */
        } else if (/* 其他情况 */) {
            /* 对应的处理 */
        } else {
            /* 不是关键字,那么定义成变量名,TOKEN_VARIABLE */
        }
        ungetc(ch, stdin); /* 退回一个字符输入 */
        /* 流指针向前进 */
    }

    显然,这个词法分析程序会自动获取能够最大接收的token,并且不需要手动写,自动就排除掉了空格,并且将每个变量名与逗号分隔了开来。

    由于Mao语言的符号数量很少,并且各个类型的token规则特意设置得很简单(浮点数甚至不包含科学计数法的形式)。写一个tokenizer还是非常容易的。不过又一个还没有提到的东西,就是词法分析整数或者浮点数的时候,前面的正负号会被识别成加减号。当然这个问题无关紧要,讲道理的话在后面的语法分析阶段也能解决。不过就当作一个练习吧。

    词法分析就到这,下次来说说语法树的事情。