BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

编写Linux内核模块——第一部分:前言

| 作者 金灵杰 关注 5 他的粉丝 发布于 2015年10月27日. 估计阅读时间: 40 分钟 | GMTC大前端的下一站,PWA、Web框架、Node等最新最热的大前端话题邀你一起共同探讨。

【编者的话】Linux内核模块作为Linux内核的扩展手段,可以在运行时动态加载和卸载。它是设备和用户应用程序之间的桥梁,可以通过标准系统调用,为应用程序屏蔽设备细节。本文来自Derek Molloy的博客,介绍了内核模块的概念、用途,以及如何构建一个简单的“Hello World”内核模块。

前言

在这系列文章中,将介绍如何为嵌入式Linux设备编写Linux内核模块。文章将从简单的可加载内核模块(loadable kernel module,LKM)“Hello World!”开始,进而开发通过使用中断请求控制嵌入式Linux设备(如BeagleBone)通用输入输出接口(GPIO)的模块。当我确定合适的应用程序时,我会添加更多的后续文章。

内核模块是一个复杂的话题,需要一定的时间来完成。因此,我将内容拆分成几篇文章,每篇提供一个可以实践的示例和结果。这个话题可以写一整本书,因此很难覆盖每一个方面。关于编写内核模块的其他文章也有很多,而本文的示例都在Linux内核3.8.X以上版本构建和测试,以确保这些材料是最新且贴切的。同时,本文主要关注嵌入式系统的硬件接口。在我的书《Exploring BeagleBone》中也有相同的示例,由于本文自身包含了这些代码,读者无须拥有该书的副本。

图1:内核空间GPIO性能

本文集中讨论构建和部署“Hello World!”内核模块所需的系统设置、工具和代码。本系列中的第二篇文章探讨了如何编写字符设备驱动和如何编写用户空间C/C++程序与内核空间模块进行交互。第三篇文章探讨内核空间GPIO库代码的使用,它结合了前两篇文章的内容,开发中断驱动代码,使之能够从Linux用户空间控制。例如,图1展示了示波器捕获的通过中断驱动内核模块处理按钮按下到LED亮起的图形。在常规嵌入式Linux中(即非实时Linux的变体),该代码展示忽略CPU开销后,响应时间大约为20毫秒(±5微秒)。

什么是内核模块

可加载内核模块(LKM)是Linux内核运行时加载和移除代码的机制。该机制对于设备驱动是理想的,这使得内核可以在不知道硬件如何工作的情况下和硬件进行交互。可加载内核模块的替代是将每个驱动代码构建到Linux内核中。

没有模块化能力,Linux内核将会变得非常大,因为它不得不支持BeagleBone开发板上所需的每个驱动。同时,在需要添加新硬件或者升级设备驱动时,必须重新构建内核。可加载内核模块功能的缺点是对于每个设备都必须维护一个驱动文件。可加载内核模块在运行时加载,他们不运行在用户空间,本质上是内核的一部分。

图2:Linux用户空间和内核空间

如图2所示,内核模块运行在内核空间,而应用程序运行在用户空间。内核空间和用户空间都有自己独立的内存地址,不会相互重叠。此方法确保了运行在用户空间中的应用程序对于硬件有一致的视图,不用关注硬件平台本身。内核服务通过系统调用以可控的方式提供给用户空间。同时,内核阻止独立的用户空间应用程序之间相互竞争或通过使用保护级别访问受限资源(比如超级用户与普通用户的权限)。

为什么编写内核模块

在嵌入式Linux中和电子电路交互,你接触到的是系统文件系统,并且使用低级别的文件操作来和电子电路交互。这种方式效率很低(尤其是如果你有传统嵌入式系统开发经验)。然而,对这些文件项进行内存映射后,对于许多应用程序来说性能是足够的。我在书中已经证明,通过在Linux用户空间使用pthread、回调函数和sys/poll.h,在忽略CPU开销下,是可以做到约三分之一毫秒的响应时间。

另一个实现是使用内核代码,它支持中断。然而内核代码难以编写和调试。我的建议是优先尝试在Linux用户空间完成任务,除非已确定没有其他可行方法。

本次讨论的源码

本次讨论的所有代码都在为《Exploring BeagleBone》准备的GitHub仓库上。代码可以在ExploringBB GitHub仓库内核工程目录中公开查看,或者也可以将代码复制到BeagleBone(或者其他Linux设备):

molloyd@beaglebone:~$ sudo apt-get install git
molloyd@beaglebone:~$ git clone https://github.com/derekmolloy/exploringBB.git

代码中/extras/kernel/hello目录是本文最重要的资源。为这些示例代码自动生成的Doxygen文档有HTML格式PDF格式

准备构建可加载内核模块的系统

为了构建内核代码,需要在设备上安装Linux内核头文件。在典型的Linux桌面机器上,可以使用包管理器来查找和安装正确的包。例如,在64位Debian发行版中,可以这样做:

molloyd@DebianJessieVM:~$ sudo apt-get update
molloyd@DebianJessieVM:~$ apt-cache search linux-headers-$(uname -r)
 linux-headers-3.16.0-4-amd64 - Header files for Linux 3.16.0-4-amd64
 molloyd@DebianJessieVM:~$ sudo apt-get install linux-headers-3.16.0-4-amd64
 molloyd@DebianJessieVM:~$ cd /usr/src/linux-headers-3.16.0-4-amd64/
 molloyd@DebianJessieVM:/usr/src/linux-headers-3.16.0-4-amd64$ ls
 arch  include  Makefile  Module.symvers  scripts

本系列的前两篇文章的示例,可以在任何桌面Linux发行版中完成构建。然而,本系列文章中,我将在BeagleBone上直接构建内核模块,这相比于交叉编译可以简化步骤。安装的内核头文件必须和内核构建版本一致。和桌面版安装类似,使用uname命令来识别正确的安装版本。例如:

molloyd@beaglebone:~$ uname -a
Linux beaglebone 3.8.13-bone70 #1 SMP Fri Jan 23 02:15:42 UTC 2015 armv7l GNU/Linux

BeagleBone平台的Linux内核头文件可以从Robert Nelson的网站下载。比如在http://rcn-ee.net/deb/precise-armhf/,选择准确的内核构建版本,并且在BeagleBone上下载和安装这些Linux内核头文件。例如:

molloyd@beaglebone:~/tmp$ wget http://rcn-ee.net/deb/precise-armhf/v3.8.13-bone70
        /linux-headers-3.8.13-bone70_1precise_armhf.deb 
100%[===========================>] 8,451,080 2.52M/s in 3.2s
2015-03-17 22:35:45 (2.52 MB/s) - 'linux-headers-3.8.13-bone70_1precise_armhf.deb' saved [8451080/8451080]
molloyd@beaglebone:~/tmp$ sudo dpkg -i ./linux-headers-3.8.13-bone70_1precise_armhf.deb 
Selecting previously unselected package linux-headers-3.8.13-bone70

然后可以检查头文件是否正确安装:

molloyd@beaglebone:~/tmp$ cd /usr/src/linux-headers-3.8.13-bone70/ 
molloyd@beaglebone:/usr/src/linux-headers-3.8.13-bone70$ ls
Documentation Module.symvers  crypto    fs       ipc     mm       scripts   tools
Kconfig       arch            drivers   include  kernel  net      security  usr
Makefile      block           firmware  init     lib     samples  sound     virt

给BeagleBone使用的3.8.13-bone47版本内核的Debian发行版中,需要执行一个特殊步骤在/usr/src/linux-headers-3.8.13-bone47/arch/arm/include/mach目录中创建一个空的timex.h文件(即touch timex.h)。bone70构建不需要此步骤。

警告

编写和测试内核模块时很容易使系统崩溃。系统崩溃可能会损坏文件系统。虽然系统崩溃不太常见,但这是可能发生的。请备份数据或者使用一个嵌入式系统,如BeagleBone,他们能够很方便的被重新刷写。通过执行sudo reboot或者按BeagleBone上的重置按钮,通常能够恢复到正常状态。在写本系列文章过程中,尽管有很多很多次系统崩溃,但BeagleBones并没有损坏过。

模块代码

传统计算机程序的运行生命周期相当简单。加载器为程序分配内存,然后加载程序和所需要的动态链接库。指令从一些入口开始执行(传统C/C++程序以main()函数作为入口),语句被执行,异常被抛出,动态内存被分配和释放,程序最终运行完成。当程序退出时,操作系统识别任何内存泄露,并释放到内存池。

内核模块不是应用程序,从一开始就没有main()函数。内核模块和普通应用程序的区别有:

  • 非顺序执行:内核模块使用初始化函数将自身注册并处理请求,初始化函数运行后就结束了。内核模块处理的请求在模块代码中定义。这和常用于图形用户界面(graphical-user interface,GUI)应用的事件驱动编程模型比较类似。
  • 没有自动清理:任何由内核模块申请的内存,必须要模块卸载时手动释放,否则这些内存将无法使用,直到系统重启。
  • 不要使用printf()函数:内核代码无法访问为Linux用户空间编写的库。内核模块运行在内核空间,它有自己独立的地址空间。内核空间和用户空间的接口被清晰的定义和控制。内核模块可以通过printk()函数输出信息,这些输出可以在用户空间查看到。
  • 会被中断:内核模块一个概念上困难的地方在于他们可能会同时被多个程序/进程使用。构建内核模块时需要小心,以确保在发生中断的时候行为一致和正确。BeagleBone有一个单核处理器(目前为止),但是我们仍然需要考虑多进程同时访问对模块的影响。
  • 更高级的执行特权:通常内核模块会比用户空间程序分配更多的CPU周期。这看上去是一个优势,然而需要特别注意内核模块不会影响到系统的综合性能。
  • 无浮点支持:对用户空间应用,内核代码使用陷阱(trap)来实现整数到浮点模式的转换。然而在内核空间中这些陷阱难以使用。替代方案是手工保存和恢复浮点运算,这是最好的避免方式,并将处理留给用户空间代码。

以上概念有很多需要消化,重要的是,它们都被解决,但是没有都包含在第一篇文章中。列表1提供了第一个示例内核模块的的代码。当没有提供内核参数时,代码使用printk()函数显示“Hello world!...”,如果提供了参数“Derek”,日志会显示“Hello Derek!...”。列表1中的注释使用Doxygen样式,描述每个语句角色。更多的描述在代码列表下放。

/**
 * @file    hello.c
 * @author  Derek Molloy
 * @date    4 April 2015
 * @version 0.1
 * @brief  入门的可加载内核模块“Hello World!”,当模块加载和移除的时候,会在/var/log/kern.log文件输出消息。
 * 该模块在加载的时候接受一个参数:名字,它将显示在内核日志文件中。
 * @see http://www.derekmolloy.ie/ 查看完整描述和补充描述。
*/

#include <linux/init.h>             // 用于标记函数的宏,如__init、__exit
#include <linux/module.h>           // 加载内核模块到内核使用的核心头文件
#include <linux/kernel.h>           // 包含内核使用的类型、宏和函数

MODULE_LICENSE("GPL");              ///< 许可类型,它会影响到运行时行为
MODULE_AUTHOR("Derek Molloy");      ///< 作者,当使用modinfo命令时可见
MODULE_DESCRIPTION("A simple Linux driver for the BBB.");  ///< 模块描述,参见modinfo命令
MODULE_VERSION("0.1");              ///< 模块版本

static char *name = "world";        ///< 可加载内核模块参数示例,这里默认值设置为“world”
module_param(name, charp, S_IRUGO); ///< 参数描述。charp表示字符指针(char ptr),S_IRUGO表示该参数只读,无法修改
MODULE_PARM_DESC(name, "The name to display in /var/log/kern.log");  ///< 参数描述

/** @brief 可加载内核模块初始化函数
 *  static关键字限制了该函数的可见范围为当前C文件。
 *  __init宏表示对于内置驱动(不是可加载内核模块),该函数只在初始化的时候执行,
 *  在此之后,该函数可以废弃,且内存可以被回收。
 *  @return 当执行成功返回0
 */
static int __init helloBBB_init(void){
   printk(KERN_INFO "EBB: Hello %s from the BBB LKM!\n", name);
   return 0;
}

/** @brief 可加载内核模块清理函数
 *  和初始化函数类似,它是静态(static)的。__exit函数表示如果这个代码是给内置驱动(非可加载内核模块)使用,该方法是不需要的。 
 */
static void __exit helloBBB_exit(void){
   printk(KERN_INFO "EBB: Goodbye %s from the BBB LKM!\n", name);
}

/** @brief 内核模块必须使用linux/init.h头文件提供的module_init()和module_exit()宏,
 *  它们标识了在模块插入时的初始化函数和移除时的清理函数(如上描述)
 */
module_init(helloBBB_init);
module_exit(helloBBB_exit);

列表1:Hello World Linux可加载内核模块代码

除了列表1注释中描述的点之外,还有一些补充的点:

  • 第16行:语句MODULE_LICENSE("GPL")提供了(通过modinfo)该模块的许可条款,这让使用这个内核模块的用户能够确保在使用自由软件。由于内核是基于GPL发布的,许可的选择会影响内核处理模块的方式。如果对于非GPL代码选择“专有”许可,内核将会把模块标记为“污染的(tainted)”,并且显示警告。对GPL有非污染(non-tainted)的替代品,比如“GPL版本2”、“GPL和附加权利”、“BSD/GPL双许可”、“MIT/GPL双许可”和“MPL/GPL双许可”。更多内容可以查看linux/module.h头文件。
  • 第21行:名字(字符类型指针)被声明为静态,并且被初始化包含字符串“hello”。在内核模块中应该避免使用全局变量,这比在应用程序编程时更加重要,因为全局变量被整个内核共享。应该使用static关键字来限制变量在模块中的作用域。如果必须使用全局变量,在变量名上增加前缀确保在模块中是唯一的。
  • 第22行:module_param(name, type, permissions)宏有三个参数,名字(展示给用户的参数名和模块中的变量名)、类型(参数类型,即byte、int、uint、long、ulong、short、ushort、bool、逆布尔invbool或字符指针之一)和权限(这是当使用sysfs时对参数的访问权限。值0禁用该项,而值为S_IRUGO运行用户/组/其他有读权限,参阅访问权限模式位指南)。
  • 第31和40行:函数可以是任何名字(如helloBBB_init()和helloBBB_exit()),但是必须向module_init()和module_exit()宏传入相同的名字,如第48和49行。
  • 第31行:printk()和printf()行数的使用方式类似,可以在内核模块代码的任何地方调用该函数。唯一重要却别是当调用printk()函数时,必须提供日志级别。日志级别在linux/kern_levels.h头文件中定义,它的值为KERN_EMERG、KERN_ALERT、KERN_CRIT、KERN_ERR、KERN_WARNING、KERN_NOTICE、KERN_INFO、KERN_DEBUG和KERN_DEFAULT之一。该头文件通过linux/printk.h文件被包含在linux/kernel.h头文件中。

从本质上讲,当模块加载时,helloBBB_init()函数将会执行。当模块卸载时,helloBBB_exit()函数会被执行。

下一步是将代码构建成内核模块。

构建模块代码

构建内核模块需要Makefile文件,事实上是一个特殊的kbuild Makefile。构建本文示例的内核模块所需要的kbuild Makefile文件参见列表2.

obj-m+=hello.o

all:
    make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) modules
clean:
    make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) clean

列表2:构建Hello World可加载内核模块需要的Makefile文件

Makefile文件第一行被成为目标定义,它定义了需要构建的模块(hello.o)。它的语法惊人的复杂,例如obj-m定义了可加载模块目标,obj-y表示内置的对象目标。当模块需要从多个目标文件构建时,语法会变得更加复杂,但这个Makefile文件对构建示例模块已经足够了。

Makefile文件中需要提醒的内容和普通Makefile文件类似。$(shell uname -r)命令返回当前内核构建版本,这确保了一定程度的可移植性。-C选项在执行任何make任务前将目录切换到内核目录。M=$(PWD)变量赋值告诉make命令实际工程文件存放位置。对于外部内核模块来说,modules目标是默认目标。另一种目标是modules_install,它将安装模块(make命令必须使用超级用户权限执行且需要提供模块安装路径)。

一切都很顺利的情况下(如已经按照前文描述安装了Linux内核头文件),构建内核模块的过程应该是非常简单的。构建步骤如下:

molloyd@beaglebone:~/exploringBB/extras/kernel/hello$ ls -l
total 8
-rw-r--r-- 1 molloyd molloyd 154 Mar 17 17:47 Makefile
-rw-r--r-- 1 molloyd molloyd 2288 Apr 4 23:26 hello.c
molloyd@beaglebone:~/exploringBB/extras/kernel/hello$ make
make -C /lib/modules/3.8.13-bone70/build/ M=/home/molloyd/exploringBB/extras/kernel/hello modules
make[1]: Entering directory '/usr/src/linux-headers-3.8.13-bone70'
CC [M] /home/molloyd/exploringBB/extras/kernel/hello/hello.o
Building modules, stage 2.
MODPOST 1 modules
CC /home/molloyd/exploringBB/extras/kernel/hello/hello.mod.o
LD [M] /home/molloyd/exploringBB/extras/kernel/hello/hello.ko
make[1]: Leaving directory '/usr/src/linux-headers-3.8.13-bone70'
molloyd@beaglebone:~/exploringBB/extras/kernel/hello$ ls
Makefile  Module.symvers  hello.c  hello.ko  hello.mod.c  hello.mod.o  hello.o  modules.order

现在,在构建目录中能够看见一个hello可加载内核模块,它的文件扩展名为.ko。

测试可加载内核模块

该模块目前能够使用内核模块工具加载:

molloyd@beaglebone:~/exploringBB/extras/kernel/hello$ ls -l *.ko
-rw-r--r-- 1 molloyd molloyd 4219 Apr 4 23:27 hello.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/hello$ sudo insmod hello.ko
molloyd@beaglebone:~/exploringBB/extras/kernel/hello$ lsmod
Module Size Used by
hello 972 0 
g_multi 50407 2
libcomposite 15028 1 g_multi
omap_rng 4062 0
mt7601Usta 639170 0

通过modinfo命令,可以获得模块的信息,这个命令能够识别出模块的描述、作者和定义的任何模块参数:

molloyd@beaglebone:~/exploringBB/extras/kernel/hello$ modinfo hello.ko
filename: /home/molloyd/exploringBB/extras/kernel/hello/hello.ko
description: A simple Linux driver for the BBB.
author: Derek Molloy
license: GPL
srcversion: 9E3F5ECAB0272E3314BEF96
depends:
vermagic: 3.8.13-bone70 SMP mod_unload modversions ARMv7 thumb2 p2v8
parm: name:The name to display in /var/log/kernel.log. (charp)

模块可以通过rmmod命令卸载:

molloyd@beaglebone:~/exploringBB/extras/kernel/hello$ sudo rmmod hello.ko

重复上述步骤,可以在内核日志中看见使用printk()函数输出的结果。建议使用第二个中断窗口查看这个可加载内核模块加载和卸载时的输出,比如:

molloyd@beaglebone:~$ sudo su -
[sudo] password for molloyd:
root@beaglebone:~# cd /var/log
root@beaglebone:/var/log# tail -f kern.log
...
Apr 4 23:34:32 beaglebone kernel: [21613.495523] EBB: Hello world from the BBB LKM!
Apr 4 23:35:17 beaglebone kernel: [21658.306647] EBB: Goodbye world from the BBB LKM!
^C
root@beaglebone:/var/log#
测试可加载内核模块自定义参数

列表1中的代码同时包含了自定义参数,它允许在初始化时向内核模块传递参数。这个功能能够这样测试:

molloyd@beaglebone:~/exploringBB/extras/kernel/hello$ sudo insmod hello.ko name=Derek

这时如果查看/var/log/kern.log文件,会看见“Hello Derek”替换了“Hello world”。不过,首先要来看下/proc和/sys文件系统。

除了使用lsmod命令,还能够通过如下方式查找当前系统已经加载的内核模块:

molloyd@beaglebone:~/exploringBB/extras/kernel/hello$ cd /proc
molloyd@beaglebone:/proc$ cat modules|grep hello
hello 972 0 - Live 0xbf903000 (O)

这里查看到的信息和lsmod命令提供的相同,但是它同时提供了已加载模块在当前内核内存中的偏移量,这个数据在调试时非常有用。

可加载内核模块在/sys/module目录下也有目录项,它提供了用户直接访问自定义参数状态的方式。例如:

molloyd@beaglebone:/proc$ cd /sys/module
molloyd@beaglebone:/sys/module$ ls -l|grep hello
drwxr-xr-x 6 root root 0 Apr 5 00:02 hello
molloyd@beaglebone:/sys/module$ cd hello
molloyd@beaglebone:/sys/module/hello$ ls -l
total 0
-r--r--r-- 1 root root 4096 Apr 5 00:03 coresize
drwxr-xr-x 2 root root 0    Apr 5 00:03 holders
-r--r--r-- 1 root root 4096 Apr 5 00:03 initsize
-r--r--r-- 1 root root 4096 Apr 5 00:03 initstate
drwxr-xr-x 2 root root 0    Apr 5 00:03 notes
drwxr-xr-x 2 root root 0    Apr 5 00:03 parameters
-r--r--r-- 1 root root 4096 Apr 5 00:03 refcnt
drwxr-xr-x 2 root root 0    Apr 5 00:03 sections
-r--r--r-- 1 root root 4096 Apr 5 00:03 srcversion
-r--r--r-- 1 root root 4096 Apr 5 00:03 taint
--w------- 1 root root 4096 Apr 5 00:02 uevent
-r--r--r-- 1 root root 4096 Apr 5 00:02 version
molloyd@beaglebone:/sys/module/hello$ cat version
0.1
molloyd@beaglebone:/sys/module/hello$ cat taint
O

这里的版本值为0.1,对应源码中的MODULE_VERSION("0.1");taint值为0,对应所选择的许可,代码中是MODULE_LICENSE("GPL")。

自定义参数查看步骤为:

molloyd@beaglebone:/sys/module/hello$ cd parameters/
molloyd@beaglebone:/sys/module/hello/parameters$ ls -l
total 0
-r--r--r-- 1 root root 4096 Apr 5 00:03 name
molloyd@beaglebone:/sys/module/hello/parameters$ cat name
Derek

这里name变量的状态可以查看到,并且读取这个值不需要超级用户权限。这是因为在定义内核参数的时候使用了S_IRUGO参数。这个值还能够设置为可写,但是模块代码中将会需要检测状态变化并依据变化做出响应。最后,可以移除模块并观察输出:

molloyd@beaglebone:/sys/module/hello/parameters$ sudo rmmod hello.ko

正如预期那样,这回在内核日志中有如下输出信息:

root@beaglebone:/var/log# tail -f kern.log
…
Apr 5 00:02:20 beaglebone kernel: [23281.070193] EBB: Hello Derek from the BBB LKM!
Apr 5 00:08:18 beaglebone kernel: [23639.160009] EBB: Goodbye Derek from the BBB LKM!

总结

希望根据此文读者能够构建第一个可加载内核模块。尽管这个模块功能非常简单,它覆盖了大量材料。到了本文的最后,读者应该对可加载内核模块如何工作有了概要认识,应该已经配置了构建系统,构建、加载、卸载了内核模块,有能力为自己的可加载内核模块自定义参数。

下一步的构建工作是通过开发一个基础字符设备驱动,让内核空间可加载内核模块能够和用户空间C/C++应用通信。请阅读《编写Linux内核模块——第二部分:字符设备驱动》,然后是更加感兴趣的任务:和通用输入输出接口交互。

查看英文原文:Writing a Linux Kernel Module — Part 1: Introduction

编后语

《他山之石》是InfoQ中文站新推出的一个专栏,精选来自国内外技术社区和个人博客上的技术文章,让更多的读者朋友受益,本栏目转载的内容都经过原作者授权。文章推荐可以发送邮件到editors@cn.infoq.com。


感谢魏星对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入InfoQ读者交流群InfoQ好读者)。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT