cloudcfg 可以看做是 kubectl 的前身,负责与 API server 的交互,只存在于上古时代的 k8s 中,我们现在接触到的都是叫做 kubectl 的命令行工具了。该组件做的事情非常简单,就是将用户的命令行操作转化为对 API server 的 HTTP 请求。
从 cloudcfg 的命令行入口开始分析,命令行代码位于 cmd/apiserver/cloudcfg.go
1 | // The flag package provides a default help printer via -h switch |
最开始定义了 cloudcfg 命令行工具提供的所有参数。
main 函数中先从命令行中获取 method, api server 的 host,资源对象的类型以及鉴权参数。
1 | method := flag.Arg(0) |
再根据 method 判断是对资源的何种操作方式。
对资源的 CURD 是通过拼接 method 和 labelQuery 作为 api server 的请求 URL 去发送 HTTP 请求,没有其他多余的逻辑。
1 | if method == "get" || method == "list" { |
还有对 rollingupdate 的操作和 controller 的操作,后面分别展开分析
判断 method 为 rollingupdate 后通过 client 执行 update
1 | else if method == "rollingupdate" { |
具体操作是通过pkg/client/client.go
中实现的 client 进行操作, client 实现了以下接口
1 | // ClientInterface holds the methods for clients of Kubenetes, an interface to allow mock testing |
可以看到就是对 tasks,RC 和 serveice 的操作。以 create tasks 为例
1 | // CreateTask takes the representation of a task. Returns the server's representation of the task, and an error, if it occurs |
可以看到同样是通过请求 api server 来完成操作的。
回到命令行的 main 函数,创建 client 后调用cloudcfg.Update(flag.Arg(1), client, *updatePeriod)
1 | // 代码路径:pkg/cloudcfg/cloudcfg.go |
大致逻辑是通过 name 获取 RC 对象,再通过 RC 对象的期望状态获取 tasks label,再通过 task label 来 list 所有 task 后更新 task。不过笔者没太看懂这里的更新逻辑,看上去把遍历 task 的时候由原封不动传回去了,或许第一个版本还不支持滚动更新?待笔者后续完整深入看完所有组件逻辑再来补充。
判断 method 为 run 时,拿到 image,replicas, name 后执行 RunController
1 | if method == "run" { |
进入到 cloudcfg.RunController 函数内部分析
1 | controller := api.ReplicationController{ |
根据外部传入的参数构造 RC 对象,然后调用 client 的 CreateReplicationController 函数,本质还是向 API Server 发起请求
1 | data, err := yaml.Marshal(controllerOut) |
再根据掺入的 servicePort 创建 service
1 | func createService(name string, port int, client client.ClientInterface) (api.Service, error) { |
同样是向 API server 发起请求。
回到 cloudcfg 命令行中,判断 method 为 stop 时执行 StopController
1 | else if method == "stop" { |
分析 cloudcfg.StopController
1 | // StopController stops a controller named 'name' by setting replicas to zero |
可以看到将 RC 的 Replicas 置为 0 后发送给了 API server。
Controller 的操作还有一个 rm
1 | lse if method == "rm" { |
cloudcfg.DeleteController 也很简单,获取当前 RC 的副本数,如果副本数不为 0 则报错退出,否则请求 API server 删除
1 | // DeleteController deletes a replication controller named 'name', requires that the controller |
我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2td5ld5xvow0o
]]>1 | git clone https://github.com/kubernetes/kubernetes.git |
api-server 是 k8s 的核心组件之一,用于接收 kubelet 的请求,并将请求信息保存到后端存储 etcd 中。核心功能是提供 k8s 各类资源对象的 CURD 等操作。
从 api-server 的命令行入口开始分析,命令行代码位于 cmd/apiserver/apiserver.go
1 | var ( |
最开始定义了 api-server 启动所需要的相关参数,上古版本的 k8s 使用了标准库自带的 flag 库,其中 util.StringList
实现了flag.Value
接口。
1 | type StringList []string |
可以看到util.StringList
用于将以逗号分割的字符串转为[]string
类型。各个命令行参数含义如下:
从 main 函数开始分析 api-server 的具体实现
1 | var ( |
registry 是对具体资源对象的后端存储的抽象,这里定义了三个 registry,并根据命令行参数判断是使用 etcd 还是内存作为存储后端。
1 | // 代码路径:pkg/registry/interfaces.go |
其中taskRegistry
是对 task 的存储抽象,task 可以当作 pod 的前身看待,实现了对 task 的 list,get,create, update, delete 的操作。
1 | // 代码路径:pkg/registry/interfaces.go |
而ControllerRegistry
是对 RC(Replication Controller) 的存储抽象,而我们现在使用的较多的是 RS(RepicateSet), RS 正是 RC 的升级,同样是实现了对 RC 的 list,get,create,update,delete 操作。
1 | // 代码路径:pkg/registry/service_registry.go |
ServiceRegistry
是对 service 的存储抽象
1 | containerInfo := &kube_client.HTTPContainerInfo{ |
storge 是对所有资源的 registry 的统一抽象,被定义为 REST 风格的资源操作接口。
1 | // 代码路径: pkg/apiserver/api_server.go |
实例化所有资源的 storage 后放在 map 中维护,用于后面 handler 的处理。
1 | s := &http.Server{ |
使用前面的 REST Storage map 和 api prefix 创建 handler,启动 HTTP 服务器等待接收请求。接下来转到 handler 分析源码
1 | // 代码路径:pkg/apiserver/api_server.go |
Golang HTTP 的标准库是通过实现 Handler 接口的 ServeHTTP 函数来实现处理请求,通过代码可以看出先对请求的 URL 进行解析获取具体的资源对象,再通过 REST storage map 拿到对应资源对象的 REST storage,最后调用 server.handleREST
来处理具体的请求。
1 | // 代码路径:pkg/apiserver/api_server.go |
可以很清晰的看出,这段逻辑是根据请求方法和请求参数对实际的资源对象进行特定的 REST 的操作。
回到 main 函数,在启动 HTTP server 之前还启动了一个 goroutine 做定时任务
1 | endpoints := registry.MakeEndpointController(serviceRegistry, taskRegistry) |
其中 util.Forever
就是周期性任务的封装
1 | // 代码路径: pkg/util/util.go |
任务实体endpoints.SyncServiceEndpoints
逻辑如下
1 | // 代码路径: pkg/registry/endpoint.go |
可以看到主要逻辑就是定时获取所有 service 列表,再遍历 service 列表查询 service 下所有 task,最后根据 task 的 endpoint 来更新 service 的 endpoints。这一段逻辑其实就是为 kubeproxy 做负载均衡用的,让 kubeproxy 知道需要代理的 endpoint 有哪些。这一块逻辑在现在的 k8s 架构中已经从 api-server 中移除了。
笔者只分析了 api-server 主体的逻辑,后续会分析具体 registry 的逻辑。
]]>/dev
目录下的文件,应用程序通过 socket 完成与网络设备的交互,在网络设备上并不体现”一切皆文件”的设计思想。驱动架构自上而下分为4层:
协议接口层主要功能是给上层协议提供接收和发送的接口。当内核协议栈需要发送数据时,会通过调用 dev_queue_xmit
函数来发送数据。同样内核协议栈接收数据也是通过协议接口层的 netif_rx
函数来进行的。传递的数据被描述为套接字缓冲区,用struct sk_buff
结构描述,该结构体定义位于include/linux/skbuff.h
中,用于在Linux网络子系统中的各层之间传输数据,该结构在整个网络收发过程中贯穿始终。
sk buffer 结构可以分为两部分,一部分是存储真正的数据包,在图中为 Packetdata,另一部分是一组指针组成。
网络设备接口层用于抽象各种不同的网络设备,用 struct net_device
来表示网络设备,该结构地位等同于字符设备的抽象描述struct cdev
。
类似于字符设备,struct net_device
结构体也提供了一个操作函数集struct net_device_ops
来描述对网卡的各种操作。
笔者基于的是 S5PV210 的 DM9000 驱动,会大体上对 DM9000 的驱动源码进行分析, 分析源码位于DM9000 源码
DM9000 的驱动是基于 platform 架构实现,首先从 platform 框架入手。
1 | static struct platform_driver dm9000_driver = { |
该函数调用了 platform_driver_register
函数注册了一个平台总线驱动,对应的平台设备的注册定义位于 xxx_machine_init
中,在笔者基于的s5pv210
kernel 上位于arch/arm/mach-s5pv210/mach-x210.c
中的smdkc110_machine_init
中,具体的分析过程省略,笔者直接列出对应的平台总线设备。
1 | /* DM9000 registrations */ |
根据平台总线的原理,驱动和设备匹配上后,会调用驱动的 probe 函数 dm9000_probe
,分段进行分析
1 | struct dm9000_plat_data *pdata = pdev->dev.platform_data; |
该部分为 struct net_device
和 struct board_info
结构体申请内存,struct board_info
定义在 DM9000 的驱动文件中,表示设备的私有数据,随后对各个指针做了挂接,并初始化了一部分 struct board_info
中的成员。
1 | db->addr_res = platform_get_resource(pdev, IORESOURCE_MEM, 0); /* dm9000 地址端口 */ |
以上代码从platform_device
中获取 DM9000 资源: 地址端口、数据端口地址和中断号, 并为端口地址 ioremap
。
1 | /* ensure at least we have a default set of IO routines */ |
根据平台设备的平台数据,DM9000 配置在了 16bit 的模式下,所以这一部分设置只有dm9000_set_io(db, 2);
是成功的。 dm9000_set_io
函数用于设置 DM9000 的读写函数。
1 | static void dm9000_set_io(struct board_info *db, int byte_width) |
设置完读写函数后,软件重启 DM9000。
1 | static void dm9000_reset(board_info_t * db) |
DM9000 通过端口来操作寄存器, 先将寄存器的偏移值或命令码写入地址端口, 再将值写入数据端口。重启 DM900 只需往地址为 0 的端口写入 1。
重启完 DM9000 后,开始读取 DM9000 的寄存器
1 | /* try multiple times, DM9000 sometimes gets the read wrong */ |
读取 vendor id 和 product id 验证是否是 DM9000。再读取 I/O mode 和 chip revision, 并根据不同 revision 对db->type
进行赋值。
1 | /* driver system function */ |
调用ether_setup
函数对ndev
成员进行初始化。
1 | void ether_setup(struct net_device *dev) |
初始化完ndev
后,设置了netdev_ops
和 mac 地址,最后调用register_netdev
函数注册了网络设备。至此,probe 函数分析完毕,紧接着把关注点放在netdev_ops
上。
1 | static const struct net_device_ops dm9000_netdev_ops = { |
当用户执行命令ifconfig eth0 up
后会调用网卡驱动的 open 函数
1 | /* |
open 函数主要做了申请收发中断、初始化 DM9000、激活设备发送队列。其中 DM900 的初始化全是对硬件寄存器的操作,在此省略。
应用程序调用send
函数去发送数据,内核协议栈会将数据构造成struct sk_buff
后放入等待队列,调用start_xmit
通知网卡发送数据。
1 | static int dm9000_start_xmit(struct sk_buff *skb, struct net_device *dev) |
由以上代码可知,先关闭发送队列,通知协议接口层停止向下递交数据包, 然后设置数据包的总长度后将数据包拷贝进 DM9000 的 TX SRAM 中,再然后置位 TCR 寄存器后网卡开始发送数据,该标志位会在发送完毕后硬件自动清 0, 最后由中断通知 CPU 数据发送完毕
在 open 函数中申请过 DM9000 的硬件中断,该中断在发送和接收完毕都会触发,在这先只关注中断处理函数的发送完毕过程
1 | static irqreturn_t dm9000_interrupt(int irq, void *dev_id) |
先禁用所有中断,然后通过读取 ISR 寄存器获取中断状态
由 bit 0 和 1 可判断是接收中断还是发送中断, 如果是发送中断,则清中断后调用dm9000_tx_done
函数
1 | static void dm9000_tx_done(struct net_device *dev, board_info_t *db) |
再次读取寄存器状态,如果发送中断未置位,则唤醒发送队列,表示协议接口层可以继续向下递交数据了。由于在dm9000_start_xmit
函数中将发送队列关闭了并且调用dm9000_tx_done
前清了中断,此时如果中断仍置位,表示出错了,所以dev->stats.tx_fifo_errors++;
以 UDP 为例,下图说明 DM9000 发送数据包的流程
由发送过程分析可知,接收也是由中断通知的。而且与发送过程共用同一个中断处理函数,当中断是接收中断时会调用dm9000_rx
函数来处理接收过程。
RX SRAM 中一个完整数据包包含 4 字节的头部,其中第一个字节固定为 0x01, 第二个字节为数据包状态,最后两个字节表示有效数据的长度。驱动代码中用这样一个结构体来表示头部,头部之后的数据才为真正有效数据
1 | struct dm9000_rxhdr { |
dm9000_rx
函数比较长,关键部分都在代码中注释说明
1 | static void dm9000_rx(struct net_device *dev) |
大体逻辑可以归为以下流程:
1.先读取 RX SRAM 中 4 字节头部到struct dm9000_rxhdr rxhdr
中
2.判断第一字节是否为 0x01, 判断数据包总长度是否符合以太网规范,最后根据头部中的状态值是否是一个正常的封包
3.经过 2 判断是正常封包后,读取有效数据
4.创建分配 sk buffer,并将有效数据拷贝到 sk buffer 中
5.调用netif_rx
, 将 sk buffer 向上递交给协议接口层
以 UDP 为例,下图说明 DM9000 接收数据包的流程
通常情况下,网络驱动以中断方式接收数据,但是当数据量大的时候会频繁产生中断,CPU 要频繁去处理中断导致效率低下而不如纯轮询模式。在 kernel 2.5 之后引入了新的处理方式,叫 NAPI,综合了中断方式和轮询方式。NAPI 这个名字取得不知所云,据说由于当时未找到合适的名字,就叫 NAPI (New API),目前已经公认为专有名词了。
NAPI 接收数据的流程:接收中断来临 -> 关闭接收中断 -> 轮询方式接收所有数据包直到为空 -> 开启接收中断 -> 接收中断来临 -> …
笔者在 DM9000 中加入了 NAPI 的支持 git commit。
主要修改如下:
1.在driver/net/Kconfig
中加入配置
1 | config DM9000_NAPI |
2.在struct board_info
添加成员
1 |
|
3.在 probe 函数中调用netif_napi_add
注册 NAPI 要调度执行的轮询函数
1 |
|
dm9000_napi_poll
函数如下
1 |
|
dm9000_rx
轮询处理完收包后,需要调用napi_complete
表示轮询完毕。
4.在 open 函数中调用napi_enable
使能 NAPI 调度
1 |
|
同样在 stop 函数中禁止 NAPI 调度
1 |
|
由于主机内看到的只有内网 IP, 而且几台云服务器位于不同的内网, 直接搭建会将内网 IP 注册进集群导致搭建不成功。解决方案:使用虚拟网卡绑定公网 IP, 使用该公网 IP 来注册集群
1 | # 所有主机都要创建虚拟网卡,并绑定对应的公网 ip |
该设置方式在重启服务器后失效,持久化需要将配置写入
/etc/network/interfaces
或/etc/netplan/50-cloud-init.yaml
将集群所有节点的公网 ip 和 hostname 对应关系写入/etc/hosts
中
1 | sudo vi /etc/hosts |
1 | sudo swapoff -a |
1 | sudo mkdir /etc/docker |
1 | cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf |
协议 | 方向 | 端口范围 | 作用 | 使用者 |
---|---|---|---|---|
TCP | 入站 | 6443 | Kubernetes API 服务器 | 所有组件 |
TCP | 入站 | 2379-2380 | etcd 服务器客户端 API | kube-apiserver、etcd |
TCP | 入站 | 10250 | Kubelet API | kubelet 自身、控制平面组件 |
TCP | 入站 | 10251 | kube-scheduler | kube-scheduler 自身 |
TCP | 入站 | 10252 | kube-controller-manager | kube-controller-manager 自身 |
协议 | 方向 | 端口范围 | 作用 | 使用者 |
---|---|---|---|---|
TCP | 入站 | 10250 | Kubelet API | kubelet 自身、控制平面组件 |
TCP | 入站 | 30000-32767 | NodePort 服务 | 所有组件 |
协议 | 方向 | 端口范围 | 作用 | 使用者 |
---|---|---|---|---|
UDP | 入站 | 8472 | vxlan Overlay 网络通信 | Overlay 网络 |
kubeadm:用来初始化集群的指令
kubelet:在集群中的每个节点上用来启动 Pod 和容器等
kubectl:用来与集群通信的命令行工具
1 | sudo apt install -y apt-transport-https ca-certificates curl |
添加 kubelet 的启动参数--node-ip=公网IP
, 每个主机都要添加并指定对应的公网 ip, 添加了这一步才能使用公网 ip 来注册进集群
1 | sudo vi /etc/systemd/system/kubelet.service.d/10-kubeadm.conf |
1 | sudo kubeadm init \ |
报错及解决:
ERROR ImagePull: failed to pull image registry.cn-hangzhou.aliyuncs.com/google_containers/coredns:v1.8.4: output: Error response from daemon: manifest for registry.cn-hangzhou.aliyuncs.com/google_containers/coredns:v1.8.4 not found: manifest unknown: manifest unknown
解决:
1.从官方镜像拉取 coredns
1
docker pull coredns/coredns
2.打 tag,修改镜像名
1
docker tag coredns/coredns:latest registry.cn-hangzhou.aliyuncs.com/google_containers/coredns:v1.8.4
3.删除多余镜像
1
docker rmi coredns/coredns:latest
初始化成功后输出如下
根据输出提示执行以下命令
1 | mkdir -p $HOME/.kube |
记录下该命令,用于之后将 worker 节点加入集群
1 | kubeadm join 139.198.108.103:6443 --token hnop0o.t16okler9962rroq \ |
kube-apiserver
参数在 master 节点,kube-apiserver 添加--bind-address
和修改--advertise-addres
1 | sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml |
在 master 节点执行
1 | # 下载 flannel 的 yaml 配置文件 |
修改 yaml 配置文件,添加两处地方和修改一处地方
1 | vi kube-flannel.yml |
1 | args: |
修改完后,开始安装网络插件
1 | kubectl apply -f kube-flannel.yml |
执行如下命令,等待一会儿,直到所有的容器组处于 Running 状态
1 | watch -n 1 kubectl get pod -n kube-system -o wide |
使用初始化 master 节点成功后输出的命令来加入集群,或者在 master 节点重新打印 token 和加入命令
1 | kubeadm token create --print-join-command |
在 worker 节点执行命令加入集群
1 | sudo kubeadm join 139.198.108.103:6443 --token wm2039.cf8qnsrgyip6qvsz --discovery-token-ca-cert-hash sha256:64c85683ac63f820e64787ed47674c7d470574feebcfe0f2142f45699cfe8ec6 |
等待所有需要加入的节点加入成功后,在 master 节点执行下面命令,并等待所有节点状态变为 Ready (笔者搭建的一主两从的集群,均使用的公网 ip)
1 | kubectl get nodes |
master 节点执行下面命令来部署 nginx
1 | kubectl create deploy my-nginx --image=nginx |
查看 nginx 部署的 pod 信息,可以看到 Pod ip,以及部署在哪一个节点上
1 | kubectl get pods -o wide |
尝试 ping Pod 的 ip,如果无法 ping 通,执行
1 | sudo iptables -P FORWARD ACCEPT |
docker 从 1.13 版本开始,可能将 iptables FORWARD chain 的默认策略设置为了 DROP,该设置会导致 ping 其他 node 上的 Pod ip 失败
查看 nginx 对外暴露的端口
1 | kubectl get all |
可以看到对外暴露的端口是 30950, 如果分别通过集群内所有节点的公网 ip 访问这个端口,能请求到 nginx 主页,则证明部署成功
下载 dashboard 的 yaml 描述文件
1 | wget https://raw.githubusercontent.com/kubernetes/dashboard/v2.6.1/aio/deploy/recommended.yaml |
修改下载下来的 recommend.yaml
1 | kind: Service |
应用修改后的 yaml 文件,创建 dashboard 服务
1 | kubectl apply -f recommended.yaml |
现在可以通过 30001 端口访问 dashboard 的登录页面了
如果使用的是 chrome 浏览器并出现了以上页面,可以鼠标点击页面任意地方,然后键盘输入 thisisunsafe。正常访问会进入 Login 页面,提示需要授权
接下来创建 admin 用户来获取 token
1 | vi dashboard-admin.yaml |
1 | apiVersion: rbac.authorization.k8s.io/v1 |
创建 admin 用户并获取 token
1 | kubectl apply -f dashboard-admin.yaml |
在 Login 页面输入 token 后就可以成功访问 dashboard 页面了
]]>每一个安装了 Docker 的 Linux 主机都会创建一个名为 docker0 的虚拟网桥,该虚拟网桥作为所有容器的默认网关。
在默认的网络模式下,虚拟网桥的工作方式和物理交换机类似,主机上的所有容器通过 docker0 连接在了一个二层网络中。
每启动一个容器,Docker 都会创建一个虚拟网卡, 并根据 Docker 网桥所在的网段来分配给容器一个未使用的 ip 地址,称之为容器 ip。在宿主机和容器内分别创建了一个虚拟接口,它们彼此连通,这对接口称之为 veth pair。
默认情况下的网络模式称之为 bridge 模式,该模式为 docker 的默认模式。在启动 docker 时可以使用--net
指定容器的网络模式
1 | sudo docker run -it --net=bridge -p 9001:6379 redis:alpine |
bridge 模式的网络转发如下图所示
除了 brideg 模式,docker 还支持 container, host, none 模式
网络模式 | 配置 | 说明 |
---|---|---|
bridge | --net=bridge | 默认模式 |
container | --net=container:name或id | 容器和另外一个容器共享 network namespace k8s 中的 pod 就是多个容器共享一个 network namespace |
host | --net=host | 容器和宿主机共享 network namespace |
none | --net=none | 不配置网络,用户可以稍后进入容器,自行配置 |
默认主机中创建了三个网络, 可通过network ls
命令查看
1 | sudo docker network ls |
用户可通过network create
命令创建自定义网络
1 | # 创建自定义网络 指定 bridge 模式,网段为 192.168.0.0/16, 网关为 192.168.0.0/16, 命名为 mynet |
启动容器可以指定连接到自定义网络
1 | # 启动两个 redis 容器,分别命名为 redis1, redis2 |
通过network inspect
命令可以查看一个网络的详情
1 | # 查看 mynet 网络详细信息,参数为网络 id 或网络名,参数可通过 network ls 命令查到 |
可以看到该网络下连接了两个容器
在以前可使用 --link
参数来使容器互联,但在自定义网络下默认就可以使用容器名进行容器的互联,内部已经维护好了容器名和 ip 的对应关系
1 | # 在 redis1 容器中访问 redis2 容器 |
注:默认的 bridge 网络不支持通过容器名进行互联
默认情况下两个网络是隔离的,如果需要让两个网络下的容器能够互相访问,可以使用network connect
命令将容器连接到另一个网络
在默认的 bridge 网络下创建一个 redis3 容器用于演示
1 | sudo docker run -it -d -P --name redis3 --net bridge redis:alpine |
默认情况下,两个不同网络的容器无法访问
将 redis3 容器加入到 mynet 网络中
1 | # docker network connect 网络名或id 容器名或id |
加入后就可以成功访问了
查看 mynet 网络信息和 ip 信息后发现,redis3 容器被分配了一个 mynet 的网络接口和 ip 地址
i++
, 由于对应的汇编指令并不只是一条,在并发访问下可能出现多个线程中的多条指令交错导致部分加操作丢失。全局变量i
属于临界资源,当然可以使用加锁的方式保护临界资源,但是加锁开销比较大,用在这里有些杀鸡焉用牛刀。最好的方式是使用内核提供的atomic_t
类型的原子变量来进行原子操作。笔者本次通过源码来窥探原子操作的底层实现, 本次仍以 arm 架构下的 kernel 2.6.35 版本为源码来源。
首先来看下atomic_t
的定义, 仅仅只是一个int
类型变量
include/linux/types.h
1 | typedef struct { |
以原子加操作为例, 来看下atomic_add
的实现
arch/arm/include/asm/atomic.h
1 | static inline void atomic_add(int i, atomic_t *v) |
先对以上需要用到的内嵌汇编知识做一个简单介绍。
内嵌汇编的格式如下:
1 | __asm__ volatile( |
内嵌汇编中引用 input 部分和 output 部分的值使用 %0, %1, %2 … 占位符, 也就是上述代码中的 result 为 %0, tmp 为 %1, v->counter 为 %2, &v->counte 为 %3, i 为 %4。
除此每个变量都以 “xx”(yy) 形式出现, 其中”xx”部分为修饰, 以下列出理解atomic_add
需要用到的修饰,其他可忽略
atomic_add
的核心是两条关键的汇编指令, ldrex
和strex
需要配套使用
1 | // 将寄存器 ry 指向的内存值 load 到寄存器 rx 中, 并记录 ry 指向的内存状态为 exclusive(独占的) |
铺垫完上述前提知识后, 以下给出对汇编代码的逐行注释
1 | static inline void atomic_add(int i, atomic_t *v) |
考虑这样的一种 case 来帮助理解, 假设有两个 cpu 发起对同一段内存的访问
1.CPU1 发起 ldrex 读操作, 记录当前状态为 exclusive
2.CPU2 发起 ldrex 读操作, 记录当前状态为 exclusive, 状态保持不变
3.CPU2 发起 strex 写操作, 状态从 exclusive 变为 open, 同时数据写回内存
4.CPU1 发起 strex 写操作, 由于当前状态为 open, 则写失败
5.CPU1 由于 strex 写失败, 根据atomic_add
的"teq %1, #0\n" "bne 1b"
逻辑会再进行 ldrex 后 strex 直到成功(这就是所谓的自旋), 所以保证了每一个加操作都不会丢失
arm 的 exclusive 标记是通过 exclusive monitor 模块实现的,在老的 x86 架构下实现类似 ldrex/strex 功能会通过锁总线实现导致效率低下
]]>eBPF 相当于在内核中有一个运行特定字节码的虚拟机,可以动态将 eBPF 字节码注入进内核。eBPF 程序会 attach 到指定的内核代码路径中,当执行到该代码路径时,会执行对应的 eBPF 程序
XDP 是专门针对于网络数据,是基于 eBPF 的高性能数据链路。可以在以下三种模式运行:
Native:工作在网络驱动早期接收队列上
Offload: 直接运行在网卡中,需要特定的智能网卡支持
Generic:对于不能支持 Native 和 Offload 模式下, 内核提供一种通用模式。该模式运行在网络协议栈处理早期,不需要特定网卡支持,但性能会远低于以上两种模式
XDP 对每个报文的处理称之为 action,支持以下action:
DROP: 在驱动层直接丢弃数据包,通常用于丢弃 DDos 攻击报文
PASS: 允许数据包进入协议栈处理,之后数据包的处理就跟传统的处理方式一样
TX:可将报文从接收到该报文的 NIC 发送出去
REDIRECT: 与 TX 模式一样,但是重定向到另一个 NIC 发送出去,或可以将数据包重定向到 AF_XDP socket 的用户空间程序 mmap 映射的内存中
ABORTED:表示程序发生了异常,效果与 DROP 一样,但可以在用户空间来监控这种异常发生
区别于传统 socket 数据流经内核协议栈的方式,XDP 程序在网卡驱动中直接取得网卡收到的数据包,然后直接送到用户态应用程序
应用程序利用 AF_XDP 协议族的 socket 接收数据。 XDP 程序会把数据帧送到一个在用户态可以读写的内存中,用户态应可在该内存中直接完成数据包的读取和写入,整个过程是完全 zero copy
使用 XDP socket 之前,需要在用户态通过 mmap 创建一段用户空间的内存,称之为 UMEM。这是一段连续的内存,被分割为若干个相同大小的 frame,每个 frame 可容纳一个数据包。
通过 socket 系统调用创建 AF_XDP socket,创建之后每个 socket 都各自分配了一个 RX ring 和 TX ring。这两个 ring 需要通过 socket 选项 XDP_RX_RING 和 XDP_TX_RING 进行注册。每个 socket 必须至少具有其中一个ring。RX 或 TX ring 存储着描述符集合,每个描述符指向 UMEM 中的一个 frame,描述符通过引用 frame 在 UMEM 中的 偏移量来引用 frame。RX 和 TX 可以共享相同的UMEM,所以一个报文无需在 RX 和 TX 之间进行拷贝。
UMEM 也包含两个 ring:Filling ring 和 Completion ring。应用程序会使用 Fill ring 下发描述符,让内核填写 RX 包数据后发送,一旦接收到报文,就绪的描述符也会被填入 RX ring,可以在用户态使用 poll
来等待就绪描述符的到来。通过写入 Completion ring,通知内核有一个或多个数据包已经就绪,请求内核进行数据发送。
]]>
awk
基本格式1 | awk '{ awk program }' file |
file 为 awk
要读取的文件,可以是一个或多个文件。如果不指定文件,则从标准输入中读取
1 | awk '{ awk program }' a.txt b.txt c.txt |
单引号内的是awk
的程序,一般使用单引号而非双引号。 awk
是按行处理文件,内部有一个隐藏的循环,即默认下逐行读取文件并运行程序
使用单引号原因:双引号中的
$
会被 shell 解析成 shell 变量引用,于是会进行 shell 变量替换。为了表示awk
程序使用的变量,所以尽可能使用单引号
awk
程序中的 {}
表示代码块
1 | awk '{print $0}' a.txt |
BEGIN
和 END
语句块1 | awk 'BEGIN{print "俺要开始读文件啦"}{print $0}END{print "俺处理完文件啦"}' a.txt |
awk
的隐藏循环awk
的隐藏循环BEGIN
或END
开头的代码块都称之为 main 代码块, main 代码块会参与 awk
的隐藏循环awk
pattern 和 action1 | awk ' |
awk
语法格式为pattern { action }
模式, 称之为awk
rule
可以将 BEGIN 与 END 代码块看成一种特殊的 pattern{action} 代码块
1 | # bool pattern |
awk
读取文件awk
读取文件时, 每读取一条记录(Record)(默认下按行读取,一行就是一条记录). 每读取一条记录,将其保存到$0
中,然后执行一次 main 代码段。
可通过修改预定义变量RS
来改变每次读取的记录模式,RS
变量表示输入记录分隔符(Record Separator),默认值为\n
RS
一般设置在 BEGIN 代码块中,因为需要在读取文件前确定好分隔符
注:
RS
变量作为输入记录分割符,所读取的每条记录不包含RS
变量值
RS
为单个字符, 则直接用该字符来分割记录RS
为多个字符,则将其作为正则表达式,只要匹配上正则表达式都用来分割记录IGNORECASE
为非零值,正则匹配时忽略大小写特殊RS
值解决特定需求:
1 | RS="" # 按段落读取 |
awk
每读取一条记录时,会设置预定义变量RT
表示记录分割符(Record Termination)。 当RS
为单个字符时,RT的值和
RS值相同。 当
RS为正则表达式时,
RT`为正则匹配的记录分隔符
awk
读取每条记录后,将其赋值给$0
和设置RT
外,还会设置NR
和FNR
这两个预定义变量
NR
: 所有文件的行号计数器FNR
: 各个文件的行号计数器,针对于多个文件输入的情况awk
读取每条记录后,将其赋值给$0
,同时还会对该条记录按照预定义变量FS
划分字段,将划分后的各个字段依次存入$1
,$2
,$3
…,同时将划分好的字段数量赋值给预定义变量NF
1 | awk '{print $NF}' a.txt # 输出 a.txt 的最后一列 |
未完待续 ~~
]]>内核中数据结构千变万化,采用传统的链表结构形式,需要为各种数据都定义出一个链表。
1 | /* data1 */ |
以上会出现大量定义链表结构,而在c++的模板语法下, 可以定义一个链表模板来解决这个问题
1 | template <typename T> |
问题的核心在于数据的千变万化,但是所要表述的结构却是统一的!那如果将统一的部分抽取出来呢?让一切的一切都尘归尘,土归土。
内核链表正是采用了如上的思想进行设计的,内核链表位于内核代码的include/linux/list.h
中,该链表定义为双向循环链表,所有的相关操作都定义在该头文件中,该文件中每个函数极为简洁。截取片段如下
1 | struct list_head { |
使用内核链表的方式,将链表节点嵌入到数据结构体中。如内核驱动中对misc设备的描述
1 | struct miscdevice { |
核心问题:通过遍历所有的struct list_head
即可拿到所有数据的list成员,而真正需要的是数据,那么如何从list成员获取到struct miscdevice
, 最直接的做法是将list成员放置在struct miscdevice
最开始处,只需指针强转即可获得,而如上所知该成员并未放到结构体的最开头,内核中的做法是可以放在任意位置,解析时使用了一个很强大的宏来进行获取。
1 |
|
内核中大量使用了该思想,凡是在物理内存中离散分布的结构,均采用此思想将结构嵌入到具体数据中实现数据的结构组织,例如在 epoll 底层和进程调度使用到的 rbtree 。
1 | struct rb_node { |
在总线设备驱动模型中,除了纵向的分层外还有横向的数据分离。包括设备和驱动的分离、主机和外设驱动的分离。
]]>
OpenResty 是一个高性能 Web 平台,打包了标准的 Nginx 核心,集成了很多常用的第三方模块。简单理解,OpenResty 是 Nginx 的加强。
安装方法跟 Nginx 基本一致,笔者采用的是 Ubuntu 20.04 的环境。
1 | sudo apt install libpcre3-dev # pcre库: 解析正则表达式 |
1 | wget https://openresty.org/download/openresty-1.15.8.3.tar.gz |
1 | cd openresty-1.15.8.3 |
安装完后的路径位于/usr/local/openresty/
下,可以看到bin/openresty
指向的正是 nginx
为了方便使用,可将/usr/local/openresty/bin
加入到环境变量中,在~/.bashrc
或~/.zshrc
中添加
1 | export PATH=/usr/local/openresty/bin:$PATH |
启动和停止命令跟 Nginx 一致
1 | # 启动 |
node.js 有 npm 包管理工具,openresy 同样也有一个类似工具叫 opm
1 | /usr/local/openresty/bin/opm list # 列出当前已安装的组件 |
1 | /usr/local/openresty/bin/restydoc nginx # 查看nginx说明 |
稍后补充剩下内容QAQ
]]>Secureboot分为安全性校验与完整性校验。
AVB阶段安全性校验和完整性校验需要依赖于vbmeta.img,相关的公钥及描述信息存储在vbmeta.img中。
Secureboot涉及到的两级:maskrom —> miniloader、miniloader —> uboot、uboot—> kernel,但在Android上Secureboot部分只实现前两级,uboot—> kernel以及之后的启动校验交由AVB进行处理。以下以maskrom —> miniloader为例讲解Secureboot流程。
使用rk提供的签名工具(rk_sign_tool)进行签名步骤及原理如下
1.该工具首先会产生一对密钥对,即:public key和privete key
2.使用SHA256计算镜像的hash,并使用privete key对镜像的hash进行RSA2048签名
3.使用SHA256计算出public key的hash
4.将镜像+第2步中签名+public key进行打包形成新的镜像
5.第3步中的hash将会烧写到efuse中
1.首先从新的镜像中获取public key计算hash值
2.从efuse中读取public key的hash值进行对比,如果相同则继续,否则启动失败
3.从镜像中获取签名,然后使用RSA2048计算hash
4.使用SHA256计算镜像的hash值,与第三步计算出来的hash进行对比,相同则继续,否则启动失败
AVB的核心结构为vbmeta,vbmeta分区存储了boot
分区的hash,而对于system
和vender
分区,哈希树紧随在各自的分区数据之后,vbmeta分区只保存哈希树描述符中哈希树的根哈希(root hash),盐(salt)和偏移量(offset)。
uboot启动后,首先需要进行vbmeta的合法性验证,即安全性校验,RK的做法是将验证vbmeta的公钥信息经过trust加密后存储在security分区,其中trust分区的安全性又是受efuse验证的Secureboot进行保证的。uboot启动kernel前先验签vbmeta,vbmeta可信后,再取出vbmeta中的相关信息来进行其他分区的校验。
AVB在验证system分区时采用了动态校验的方式进行完整性校验,所以采用了分块进行hash的方式来校验。那么如何存储该数据块的hash,直接采用最暴力的方式,自然而然想到的是使用一个hash列表来存储。但是使用Hash列表来保证数据块的正确性还不够,黑客修改数据的同时,如果将Hash列表也对应修改了,这就无法保证数据块的正确性了。所以需要引入一个顶层的hash,将hash列表里的每个hash字符串拼在一起后再做一次hash运算,最后的hash值称之为root hash,只要保证该root hash的正确性即可。
但是AVB并未采用该简单结构。假设system的大小为1GB,数据块大小为4KB,则有26万个数据块,对应着hash列表就有26万个元素。AVB进行运行时校验,设备运行时读到哪个块就会对哪个块校验,将需要校验的块进行hash后更新具有26万个元素的hash列表中的一个元素后计算root hash,再与vbmeta中root hash作对比来判断数据是否正确。这个效率可想而知非常糟糕,所以AVB采用了一种称为Merkle Tree的树结构。
Merkle Tree,通常也被称作Hash Tree,其叶子节点是数据块或者文件的hash值。非叶节点是其对应子节点串联字符串的hash。Hash 列表可以看作一种特殊的Merkle Tree,即树高为2的多叉Merkle Tree。
建树过程:
在树的最底层,和hash列表一样,将数据分成若干个小的数据块,有相应的hash与之对应。但是往上走,并不是直接去计算root hash,而是把相邻的两个hash合并成一个字符串,然后计算这个字符串的hash,将这个hash值作为两个节点的父节点。按照同样的方式,可以得到数目更少的新一级hash,最终必然形成一棵树,树的根节点即为root hash。
Merkle Tree的结构非常易于同步大文件或文件集合,按照查找树的查找思路,从root hash开始比对,依次往下查找到叶子节点即能找到需要重新同步或下载的数据块,其时间复杂度为O(logN),如果采用hash列表的方式,需要完整进行一遍遍历才能定位到不同的数据块,其时间复杂度为O(N)。Merkle Tree在数字签名、P2P网络、区块链等技术都有应用。回到本文介绍的AVB,AVB在运行时校验某一块时只需要更新Merkle Tree的一个分支即可计算出hash root,其运算时间比hash列表大大减少。在Android9上使用avbtool的python代码进行hash tree的生成,该算法跟上文描述略有不同,当1G的system进行4KB大小的划分,其生成的hash tree只有四层(包括root hash这一层),所以运行时计算hash只要沿着这个四层树的分支计算即可,可想而知效率大大提升。
以下分析一下Android9上hash tree的生成过程,涉及到用Python实现的avbtool源码的两个函数:calc_hash_level_offsets
,generate_hash_tree
calc_hash_level_offsets
1 | def calc_hash_level_offsets(image_size, block_size, digest_size): |
Android9上将hash tree存储在bytearray中,所以需要事先计算好树的每一层在bytearray中的偏移,以及整个树需要多长的bytearray存储。注意,hash tree的建树过程上自下往上的。其实从calc_hash_level_offsets
函数就可大致看出Android上hash tree的存储形态了,但更为形象的存储结构还是需要看generate_hash_tree
函数。
generate_hash_tree
1 | def generate_hash_tree(image, image_size, block_size, hash_alg_name, salt, |
通过calc_hash_level_offsets
函数计算好偏移和大小后,即可将参数传递给generate_hash_tree
函数来建树了。 从建树代码的循环过程可以看出,该树的实现是将生成的hash拼接在一起作为这一层的数据,然后分块进行hash后再拼接在一起给到父层,而不是之前的描述Merkle Tree的两两子节点合并后计算hash作为父节点。
Node.js是基于chrome浏览器中的v8引擎而构建的js运行时环境, 并提供了一系列的工具模块和一个包管理工具npm. Node脱离于浏览器运行, 并提供了一系列自带的os相关接口, 从而使其能像传统后端语言一样操作文件、获取os相关信息等.
1 | sudo apt-get install nodejs # 安装nodejs |
1 | npm help # 查看帮助 |
npm
会去国外服务器下载包, 淘宝在国内做了完整的npmjs.org
镜像, 可以用cnpm
代替npm
下载包.
1 | # 安装cnpm |
或者仍然使用npm
, 但指定使用淘宝的镜像源进行下载
1 | # 每次下载都指定--registry参数 |
Node.js中的模块概念, 类似于python中的模块, 一个js文件即是一个模块.
通过require
函数加载模块, 加载模块时会去执行模块内的代码. Node.js加载模块跟python一样, 只有第一次加载时会去执行模块内的代码后将其加载到内存中, 随后再去加载仅仅是将在内存中存在的模块增加一次引用而已, 并不会再去执行模块内代码.
1 | // 加载自定义模块, 一个js文件就是一个模块, 去掉后缀名即模块名 |
Node.js是模块作用域, 各模块之间相互隔离, 如果需要将模块内变量暴露出去, 则需要通过node的内置对象module.exports
导出. require
函数的返回值即是导入的模块的module.exports
对象.
cai.js
1 | const add = function(a, b) { |
hello.js
1 | // require返回值即是cai.js中的module.exports对象 |
注: 为了使用方便, node内置
exports = module.exports
, 所以也可以使用exports
对象暴露, 但如果需要让require
函数返回自定义值, 则必须向module.exports
赋值而不是exports
express是基于node.js的web框架, 是node.js的一个第三方模块.
1 | npm install express --save |
express_demo.js
1 | const express = require('express') |
运行server
1 | sudo node express_demo.js |
每次修改代码都要重新运行, 解决方法是用nodemon
代替node
运行js代码
1 | # 安装nodemon |
安装
1 | npm install --save art-template |
配置使用
1 | const express = require('express') |
**中间件(middleware)**是介于请求到响应的整个流程的一道过程, express中使用app.use
方法注册中间件, 每个中间件是一个回调函数, 接收三个参数, 依次为request、response、next回调函数(代表下一个中间件). 在中间件中调用next
函数则会将request和response传递给下一个中间件.
1 | const express = require('express') |
运行后, 访问127.0.0.1
, 控制台输出以下内容
1 | server is running, listening port 80 ... |
中间件默认对所有url进行处理, 如果需要对特定的url进行处理, 可以通过req.url
参数来判断
1 | app.use((req, res, next) => { |
除了通过request对象来获取url外, app.use
方法允许接收一个url字符串作为第一个参数
1 | app.use('/', (req, res, next) => { |
get请求的参数可以通过req.query
获取
1 | app.get('/login', (req, res) => { |
而post请求, 在express中没有内置获取post请求参数的api, 需要使用第三方模块body-parser
作为中间件进行注册.
安装
1 | npm install body-parser --save |
配置使用
1 | const express = require('express') |
可以将路由相关代码从主入口文件中单独抽离出来, 然后在主入口文件中引用.
router.js
1 | const express = require('express') |
app.js
1 | const express = require('express') |
在express中默认不支持Cookie和Session, 需要通过第三方模块express-session
解决.
安装
1 | npm instlal cookie-parser --save |
配置
1 | const express = require('express') |
使用
1 | // 设置cookie, maxAge为过期时间, 以ms为单位 |
安全增强型 Linux(Security-Enhanced Linux)简称 SELinux,它是 Linux 的一个安全子系统。SELinux 主要作用是最大限度地减小系统中服务进程可访问的资源(最小权限原则)。对资源的访问控制分为两类: DAC和MAC.
在未使用SELinux的系统上, 对资源的访问是通过权限位来确定, 比如一个文件对所属用户是否有读、写、执行权限, 其他用户的访问可由所属用户进行配置. 这种由所属用户自己决定是否将资源的访问权或部分访问权授予其他用户,这种控制方式是自主的,即自主访问控制(Discretionary Access Control, DAC).
1 | > ls -l note |
在使用了 SELinux 的系统上,对资源的访问除了通过权限位判定外,还需要判断每一类进程是否拥有对某一类资源的访问权限。这种方式对资源的访问控制, 称之为强制访问控制(Mandatory Access Control, MAC).只给每个进程开放所需要的资源, 将权限开放到最小, 当进程出现漏洞时也只会影响到该进程所涉及的资源, 这大大提升了安全性.
SELinux 有三种工作模式,分别为:
通过执行getenforce
命令可以获取当前SELinux的工作模式
在Android系统开发中, 可能会遇到SELinux的权限不足而引起的各种问题. 可以尝试将SELinux工作模式临时改为宽容模式看问题是否解决, 来判定是否是SELinux引起的问题.
1 | # 修改工作模式为宽容模式 |
遇到权限问题时, 在log中会打印avc denied提示缺少什么权限, 可以通过dmesg | grep avc
过滤出所有avc denied.
笔者在RK Android9.0上进行操作, 权限文件以.te
为后缀, 涉及到需要修改的路径:
android/device/rockchip/common/sepolicy
android/device/rockchip/rk3399/sepolicy
Android自带的进程服务通过以上目录配置即可 , 自己添加的第三方进程需要添加到自定义的目录下
以如下所示的avc denied为例讲解
1 | # avc: denied { 操作权限 } for pid=7201 comm=“进程名” scontext=u:r:源类型:s0 tcontext=u:r:目标类型:s0 tclass=访问类别 permissive=0 |
主要关注以下内容:
denied {read}
: 表示缺少read权限scontext=u:r:hal_audio_default:s0
: 表示hal_audio_default缺少了权限tcontext=u:object_r:default_prop:s0
: 表示是对default_prop缺少了权限tclass=file
: 表示缺少权限的资源类型为file因此只要在hal_audio_default.te文件中加入下面内容即可xia
1 | allow hal_audio_default tcontext:file read; |
如果需要赋予read, open权限, 当有多个权限时用{}
包裹
1 | allow hal_audio_default tcontext:file { read open }; |
或者参考android/system/sepolicy/public/global_macros
中赋予一个复合权限, 如r_file_perms
表示{ getattr open read ioctl lock map }
以上内容都是基于Android中自带的进程服务进行配置, 如果是自己引入的进程服务, 则需要自行创建.te
文件, 这部分内容后续再来填坑QAQ
笔者以Java中调用C编写的add函数为例讲解,首先创建Hello.java
和native.c
。在Android Studio下使用JNI中会讲解C与C++在JNI中的不同,并采用C++来讲解JNI。
在Hello.java
中声明一个本地方法,并在静态代码块中加载对应的动态链接库。
1 | public class Hello { |
Java调用C函数需要做C函数和Java本地方法的映射,建立该映射有两种方式: 显式映射和隐式映射。
确保Java文件中不指定包名,指定了包名后在命令行下可能会出错,一般步骤如下:
1.包含jni.h
头文件
/usr/lib/jvm/java-1.8.0-openjdk-amd64/include
其中jin.h
又包含了jni_md.h
/usr/lib/jvm/java-1.8.0-openjdk-amd64/include/linux
2.实现C函数
3.将C函数加入到映射数组中
4.实现JNI_OnLoad
函数
在native.c
中实现以上步骤
1 |
|
编译运行
1 | # 生成动态链接库 |
在Hello.java
的第一行指定包名
1 | package cn.caiyifan.jni; |
采用隐式映射的方式不需要程序员去手动建立链接,JNI规范已经使用了一套映射规范,在C函数中实现的函数名格式:Java_包名_类名_Java方法名,需要注意的是包名以’_‘隔开,而不是’.‘
1 |
|
编译运行
1 | # 生成动态链接库 |
在Android Studio中使用JNI,借助IDE带来的自动生成功能,就变得很方便。注意笔者使用的Android Studio版本是3.4.2。先讲解JNI中C与C++的不同后,再在Android Studio下使用C++来进行JNI开发。
从jni.h
源码中可以看到JNIEnv
的类型是不同的
1 |
|
由于C++是面向对象的,而C非面向对象,但C如果需要以面向对象方式封装JNI的操作函数,则需要将函数指针封装在结构体内,调用的时候需要传递本结构体的地址,所以在C中调用JNI的方法是下面这样调用的,以NewStringUTF
为例
1 | (*env)->NewStringUTF(env, "hello world"); |
通过jni.h
源码可知,C++的JNIEnv
的作法是包裹C的JNIEnv
后,在内部传递this指针进行调用的。所以在C++中直接以对象调用方法的方式调用即可
1 | env->NewStringUTF("hello world"); |
创建Android工程时,选择Native C++。
创建完的工程会比常规的Android工程在src/main下多出一个cpp目录,这是IDE自动生成,编写的C/C++函数放在这个目录下即可。
创建一个Jni.java
文件,将Jni的native接口封装成一个单例类。
1 | package cn.caiyifan.jnidemo; |
并在Jni类中添加一个getStringFromJni
的native方法。
1 | public native String getStringFromJni(); |
这时候Android IDE会报错,提示Cannot resolve corresponding JNI function Java_cn_caiyifan_jnidemo_Jni_getStringFromJni,这个报错是因为没有实现对应的本地函数,只需要按下快捷键Alt+enter,就会在对应的C/C++文件中生成对应的函数接口。
1 | extern "C" |
可以看到函数名正是JNI规范要求的格式。修改该函数
1 | extern "C" |
然后就可以在MainActivity
中调用cpp函数了
1 | package cn.caiyifan.jnidemo; |
运行到模拟器后,就可以发现成功调用了。
在C++中调用Java一般分为四步:
1.获取字节码对象
2.获取jmethodID对象
3.通过字节码对象创建jobject对象
4.通过jobject对象调用方法
其中第3步可视情况省略,当需要调用的Java方法正好位于调用该本地函数的类内,那么JNI函数的第二个参数即表示该对象
在Jni.java
中创建一个log_i方法,该方法用来输出log,供C++调用。并且声明一个native方法,在对于的Jni函数中来回调log_i方法。
1 | public void log_i(String tag, String msg) { |
在对应的Cpp函数中回调该log_i方法。对象
1 | extern "C" |
最后在MainActivity.java
中调用该本地方法
1 | // 获取Jni对象 |
运行后会发现成功在logcat上进行了打印。
签名的格式为: (参数签名)返回值签名
Java类型 | JNI类型 | C/C++类型 | 签名 |
---|---|---|---|
boolean | jboolean | unsigned char | Z |
byte | jbyte | char | B |
char | jchar | unsigned short | C |
short | jshort | short | S |
int | jint | int | I |
long | jlong | long long | J |
float | jfloat | float | F |
double | jdouble | double | V |
类 | jobject | void * | L用/隔开的全类名; |
类: 例如String的签名为Ljava/lang/String; 注意: 包名和类名用/隔开, 结尾有一个;
数组:用[表示数组签名, 例如int[]的签名为[I
javah
可以生成Java本地方法对应的C/C++函数接口,用法是指定一个class文件,不过在Android Studio中已经可以快捷键生成了。
1 | javah cn.caiyifan.jnidemo.Jni |
javap -s
可以生成一个Java文件所有方法的签名,用法与javah
一样
1 | javap -s cn.caiyifan.jnidemo.Jni |
但在Android Studio中目录结构确定编译后的class目录比较复杂,可以在工程根目录下使用以下命令
1 | javap -s `find -name Jni.class` |
附: 本文默认读者熟悉Linux设备驱动模型,不熟悉的可以先阅读这两篇blog。
Linux驱动之I2C子系统剖析
Linux驱动之SPI子系统剖析
PCI系统总体布局组织为树状,从CPU连接的Host Bridge引出PCI主桥,主桥连接的是PCI总线0,可以直接连接PCI设备,或者再挂上PCI桥引出下一级PCI总线。
每个PCI设备由一个总线号、设备号和功能号确定。PCI规范允许一个系统最多拥有256条总线,每条总线最多带有32个设备,每个设备可以是最多8个功能的多功能板,但是对于大型系统而言总线数不够,故还支持PCI域,每个PCI域可最多支持256个总线。
在PC机上可以使用lspci
查看计算机上PCI设备信息,笔者在自己电脑上执行该命令后输出如下
每一行表示一个PCI设备或者PCI桥,而每行的开头即表示总线号、设备号和功能号。
所有的PCI设备都有至少256字节的地址空间,其中前64字节是标准化的,被称为PCI配置寄存器,剩下的字节是设备相关的 (取决于具体的厂商,需要查看datasheet得知)。
PCI配置寄存器如下图所示。
硬件标识信息在硬件出厂时就写入相应设备中了。
当BIOS启动时,会为每个PCI设备分配内存、IO空间以及irq号,并写入相应PCI设备的配置寄存器中。Linux内核启动时会从PCI设备的配置寄存器里读取内存/IO起始地址以及irq,并把这些信息赋值给struct pci_dev
的相应成员来生成软件描述的PCI设备。
从上图的寄存器分布中可以看到中间有一段地址空间描述BARS(Base Address Register),这些寄存器组用来存储备PCI设备工作时的io地址、irq号和mem地址起始地址以及长度。这些信息存储的具体位置需要查阅相应PCI设备的datasheet方可得知,在内核中提供了以下几个接口来获取这些资源。
1 | /* |
内核提供了一组接口来访问配置空间。
1 | int pci_read_config_byte(struct pci_dev *dev, int where, u8 *val) |
BIOS在启动时,会为每个PCI设备分配地址和irq等信息,并写入各个PCI设备的配置寄存器中,所以PCI设备无需像其他总线那样去注册设备。
内核中使用struct pci_dev
来描述PCI设备的抽象。当linux系统启动时,会探测系统中的所有PCI设备,并为探测到的每个PCI设备做如下操作:
1.分配一个struct pci_dev
结构体,用来表示相应的PCI设备
2.为这个结构体填充设备vendor id、device id、subvendor id、subdevice id以及地址和irq信息(通过读取PIC配置寄存器得到)
3.最后把这个struct pci_dev
结构体挂接到pci_bus
上
内核中使用struct pci_driver
来描述PCI驱动的抽象
1 | struct pci_driver { |
其中id_table
用来匹配设备
1 | struct pci_device_id { |
PCI驱动的注册接口为pci_register_driver(struct pci_driver *drv)
,当调用该接口后,会调用PCI总线下的match方法来进行匹配
1 | static int pci_bus_match(struct device *dev, struct device_driver *drv) |
可以看到pci_bus_match
调用的是pci_match_device
函数
1 | static const struct pci_device_id *pci_match_device(struct pci_driver *drv, |
最终调用的是pci_match_id
匹配
1 | const struct pci_device_id *pci_match_id(const struct pci_device_id *ids, |
遍历id_table,调用pci_match_one_device
进行严格匹配
1 | static inline const struct pci_device_id * |
分别对vendor、device、subvendor、subdevice和class进行匹配,除非某一项配置为PCI_ANY_ID
,否则都要进行严格匹配,只要有一项匹配不上则直接匹配失败。
SPI总线由四根通信线组成,全双工、主从方式串行同步通信,一次传输8bit,高位在前,低位在后。
MOSI(Master Out Slave In): 主设备输出从设备输入
MISO(Master In Slave out): 从设备输入主设备输出
SCLK:同步信号的时钟线
CS: 片选线,通过片选来选择与哪一个从设备通信
注: 与I2C对比,由于SPI采用的是两根单向的数据线,而不是I2C采用的双向数据线,所以SPI为全双工通信,而I2C半双工。
I2C选择总线上挂接的一个从设备是使用从地址来区分的,而SPI采用的是CS片选线
Linux中的主从模式的总线子系统采用的是同一种分离思想,其分离的具体策略大同小异,同样分为设备驱动层、核心层、总线驱动层。具体的分离策略详细分析可参考Linux驱动之I2C子系统剖析中内核对I2C子系统框架的阐述。笔者在这与I2C子系统类比,列出数据结构名。
I2C | SPI | |
---|---|---|
主机适配器(控制器) | struct i2c_adapter | struct spi_master |
机控制器的操作方法 | struct i2c_algorithm | struct spi_bitbang |
从机设备 | struct i2c_client | struct spi_device |
从机设备板卡信息 | struct i2c_board_info | struct spi_board_info |
从机设备驱动 | struct i2c_driver | struct spi_driver |
一次完整的数据包 | struct i2c_msg | struct spi_transfer |
多个完整数据包的封装 | 无 | struct spi_message |
由于子系统架构与I2C等总线类似,所以不会在一些重复部分展开,具体分析可以参考的Linux驱动之I2C子系统剖析中的分析方法。
SPI核心层代码位于drivers/spi/spi.c
中, 从init函数开始分析
1 | static int __init spi_init(void) |
spi_init
函数如同I2C核心层中的init函数一样做了两件事,注册SPI总线和创建SPI类,这是内核驱动模型的基本套路,就不比多说了。接下来看下match函数
1 | static const struct spi_device_id *spi_match_id(const struct spi_device_id *id, |
可以看到,SPI设备和驱动的匹配是先匹配id_table中的name和设备的modalias,然后匹配驱动的name和设备的modalias。
SPI的控制器驱动,即总线驱动层位于drivers/spi/spi_s3c24xx. c
中,从init函数开始分析。
1 | static int __init s3c24xx_spi_init(void) |
会发现SPI控制器驱动并不是用的是platform_driver_register
接口来注册的,而是使用了另一个接口platform_driver_probe
, 其实这是内核提供的不支持热插拔方式的专用平台总线驱动的注册接口,该接口接受两个参数,第一个就是熟知的struct platform_driver
,第二个则是probe函数,当驱动和设备匹配上后就会调用这个probe函数。进入到 s3c24xx_spi_probe
函数进行分析,probe函数的代码比较多,分段进行分析。
1 | struct s3c2410_spi_info *pdata; |
实例化SPI控制器后设置SPI的私有数据,然后初始化completion。
1 | /* initialise fiq handler */ |
这一段初始化s3c24xx_spi
结构体中的handler,为其绑定中断处理函数,然后设置了主机控制器支持的SPI模式,设置master的片选线编号和总线编号。
1 | hw->bitbang.master = hw->master; |
bitbang表示的是SPI的操作方法,这一段关键是填充了setup_transfer,即传输方法。
1 | res = platform_get_resource(pdev, IORESOURCE_MEM, 0); |
这一段是跟具体硬件息息相关的,从获取平台资源开始,然后分别做了IO的映射、中断的申请与中断处理函数的绑定、时钟的初始化和片选的GPIO的申请和拉高电平。最后关键是调用了s3c24xx_spi_initialsetup
函数,该函数内部最后调用了spi_register_master
方法来注册SPI控制器。类比I2C在probe函数中调用的i2c_add_numbered_adapter
函数,其内部会扫描SPI的板卡信息,然后利用板卡信息生成SPI设备,并将控制器spi_master
挂接到spi_device
上,随后在SPI设备驱动层中注册设备驱动后调用probe函数会获取到该spi_device
,然后即可通过spi_device
中挂接的spi_master
来调用控制器的操作方法spi_bitbang_transfer
来传输数据。要注意的是SPI与I2C提供的通用设备驱动不同,其设备节点的生成并不是在注册主机控制器中完成的,而是在通用设备中完成的,这一段从之后设备驱动层的分析可以看出。这一段逻辑类似于I2C,就不参考源码分析了。(好吧,一如既往的懒QAQ)
SPI通用设备驱动位于drivers/spi/spidev.c
中,从init函数开始。
1 | static struct spi_driver spidev_spi_driver = { |
有空再写了,先休息啦
]]>Linux驱动分为字符设备驱动、块设备驱动和网络设备驱动,而字符设备又包括很多种,内核使用主设备号来区分各个字符设备驱动,在include/linux/major.h
文件中已经预先定义好了各类字符设备的主设备号,但是即便如此,仍然存在着大量字符设备无法准确归类,对于这些设备,内核提供了一种Misc(杂项)设备来安放它们的去处。
Misc子系统使用一个统一的主设备号来管理,当需要注册Misc驱动时,内核会为其分配次设备号。而如果采用普通字符设备驱动的方式,无论主设备号是静态分配还是动态分配,都会消耗掉一个主设备号,而且如果系统存在着大量的无法准确归类的字符设备驱动,那会大量浪费主设备号;当需要开发一个功能较简单的字符设备驱动,导出接口让用户空间程序方便地控制硬件,只需要使用Misc框架提供的接口即可快速地实现一个Misc设备驱动。
Misc框架位于driver/char/misc.c
文件中,从misc_init
函数开始分析
1 | static int __init misc_init(void) |
先是创建了Misc类,随后将Misc子系统实现为字符设备驱动来注册到内核中,并为其绑定了fops。
1 | static const struct file_operations misc_fops = { |
fops只实现了open方法,暂且不分析fops,先分析内核为驱动开发人员导出的注册接口misc_register
1 | int misc_register(struct miscdevice * misc) |
从上面可以看到,先查找设备是否已经注册(内核采用一个链表来管理已经注册的Misc设备驱动),然后判断是否需要动态分配次设备号(内核使用位图来管理已经注册的Misc次设备号),然后生成设备号,通过device_create
函数在Misc类下创建设备,这时候/dev
目录下就会根据misc->name
的值生成设备节点,然后将已经注册的驱动添加到链表上。
把关注点放到该接口需要传递的结构体struct miscdevice
1 | struct miscdevice { |
可以看到该结构体内部也定义了一个fops,需要驱动开发者使用该接口时实现一个fops,其实这个才是真正的fops,而在misc_init
函数中调用register_chrdev
来绑定的fops是用来中转数据的,具体情况可以从其open方法可以分析出来。
1 | static int misc_open(struct inode * inode, struct file * file) |
遍历用来管理Misc设备驱动的链表,根据次设备号来找到真正的由驱动开发者用misc_register
接口注册的Misc驱动,然后获取其fops,该fops就是真正的fops。然后替换真正的fops,之后再调用其他接口(write、ioctl、close)时调用的则是真正的fops,所以用来中转数据的那个fops只定义了一个open方法。最后,该open方法并不是真正的open方法,所以需要调用真正的fops中的open方法。
Misc子系统使用同一个驱动来向上提供多个设备文件节点,向下控制多个(相应的)设备。Misc驱动本质上也是字符驱动,只是它借用Misc子系统的框架来更方便的向内核注册而已。驱动开发人员只需要把Misc设备的一些基本信息通过struct miscdevice
来构建,再通过misc_register
接口向内核注册即可。
I2C总线由两根传递数据的双向信号线与一根地线组成,半双工、主从方式通信。
每个设备都有一个唯一设备地址,一次传输8bit,高位在前,低位在后。
一次完整的I2C通信需要经历一个完整的时序,I2C总线通信完整时序如下图。一般在驱动中无需关心具体时序,只需操作SoC中的I2C控制器即可,只有在裸机下需要用GPIO模拟I2C通信时才需用到,所以笔者在本文不阐述I2C时序(其实就是懒 O__O “…)。
总线速度有三种模式
drivers/i2c/i2c-dev.c
中。 这种方式仅仅只是封装了I2C的基本操作,相当于只是封装了I2C的基本时序,向应用层只提供了I2C基本操作的接口,该接口通用于所有的I2C设备。具体设备相关的操作,需要开发者在应用层根据硬件特性来完成对设备的操作。该方式的优点就是通用,而缺点也很明显,封装的不够彻底,需要应用开发人员对硬件有一定程度的了解。源码中会涉及到一部分SMBus相关内容,SMBus是Intel在I2C的基础上开发的类似I2C的总线,本文不探讨SMBus相关内容(其实说白了,还是懒QAQ)。笔者会大体上对I2C子系统的源码进行分析,如若分析的有出入,还望指出。
I2C核心层的实现位于drivers/i2c/i2c-core.c
中,笔者从i2c_init
函数开始分析。
1 | static int __init i2c_init(void) |
该函数先是调用了bus_register
函数注册了I2C总线,随后调用i2c_add_driver
函数来注册了一个虚假的I2C驱动。
先对注册的I2C总线i2c_bus_type
进行分析
1 | struct bus_type i2c_bus_type = { |
根据Linux设备驱动模型的原理,I2C总线下会挂载两条链表,分别为设备链和驱动链,只要其中一个链表有结点插入,即会通过i2c_device_match
函数来遍历另一条链表去匹配设备与驱动,一旦匹配上则会调用i2c_device_probe
函数,而i2c_device_probe
函数又会调用i2c_driver的probe
函数。进到i2c_device_match
和i2c_device_probe
进行分析。
1 | static int i2c_device_match(struct device *dev, struct device_driver *drv) |
可以看到, i2c_device_match
函数调用的是i2c_match_id
函数来进行匹配。从源码中可见,需要注意的是I2C总线匹配方式不同于Platform总线,I2C总线只匹配id_table
中的name,并不会去匹配driver中的name。
1 | static int i2c_device_probe(struct device *dev) |
可以看到,的确是调用driver->probe
来进行真正的probe。需要注意的是if (!driver->probe || !driver->id_table) return -ENODEV;
中对id_table
进行了非空判断,所以如果采用设备树方式进行匹配也需要对.id_table
进行有效赋值,否则会出现match上了但probe函数不会调用的奇怪现象,个人感觉这应该是个bug,毕竟这个核心层在设备树出现之前就已经存在了。
回到i2c_init
函数,然后注册了一个空的名为dummy
的i2c_driver。
1 | static int dummy_probe(struct i2c_client *client, |
可以看到这是一个完全空的虚假驱动,而I2C核心层为何要注册一个假的驱动不得而知,笔者查阅了网上资料也没法得知,但是/sys/bus/i2c/drivers/dummy
确实存在,所以笔者猜测应该纯粹是开发该层次调试用的。
核心层还提供了一系列函数接口供驱动开发者注册和注销驱动:
其他函数暂不分析,在分析其他层的时候调用时再进行分析。
笔者先从内核提供的通用驱动开始分析,最后在文末给出特定驱动的分析。内核提供了一个通用于所有设备的I2C设备驱动,用户可以在应用层实现对I2C的驱动,其实现位于drivers/i2c/i2c-dev.c
中。同样从init函数开始,笔者从i2c_dev_init
函数开始分析。
1 | static int __init i2c_dev_init(void) |
i2c_dev_init
函数先是调用了register_chrdev
函数注册了一个字符设备驱动,并提供了一个file_operations。由此可见,是将通用驱动实现为字符设备驱动,并由其file_operations结构体的方法为应用层提供通用接口。然后调用class_create
创建了一个类,但是可以看到并没有调用device_create
在该类下创建设备,所以注意在这里并没有生成设备节点。最后调用i2c_add_driver
注册了一个I2C从机设备驱动i2cdev_driver
。i2cdev_driver
定义如下。
1 | static struct i2c_driver i2cdev_driver = { |
从上可以看到并没有对id_table
进行赋值,从上文在I2C核心层分析可知,I2C总线是根据id_table
进行匹配,所以这里并不会按照常规的Linux驱动模型进行match后probe,况且这个驱动里也没有probe方法。所以这到底是什么情况?别慌,虽然没有id_table和probe,但是它单独提供了两个方法attach_adapter
和detach_adapter
。这里先埋个伏笔,不做分析,到I2C总线驱动层分析后自然会柳暗花明。
笔者使用的SoC是S5PV210,其控制器跟S3C2410基本一致,所以三星的驱动开发者并没有再去写一份S5PV210的主机适配器驱动,而是使用了S3C2410的主机适配器驱动,其位于drivers/i2c/busses/i2c-s3c2410.c
中。
从i2c_adap_s3c_init
函数开始分析。
1 | static int __init i2c_adap_s3c_init(void) |
可以看到其作为平台设备驱动而实现,注册了s3c24xx_i2c_driver
驱动。
1 | static struct platform_device_id s3c24xx_driver_ids[] = { |
根据平台总线的原理,很容易得知在arch/arm/mach-s5pv210/mach-x210.c
中对其驱动对应的设备进行了注册,其注册的设备定义位于dev-i2c0.c
,这是I2C的资源文件。其定义的资源如下。
1 | static struct resource s3c_i2c_resource[] = { |
由name可知,与s3c24xx_i2c_driver
是匹配的。除此之外,还定义了平台数据default_i2c_data0
和default_i2c_data0
函数。其相关的调用还是在arch/arm/mach-s5pv210/mach-x210.c
中进行的,在mach-x210.c
中的smdkc110_machine_init
函数中进行了如下调用
1 | /* i2c */ |
现在进到s3c_i2c0_set_platdata
函数进行分析。
1 | static struct s3c2410_platform_i2c default_i2c_data0 __initdata = { |
可以看到传递NULL则使用了默认的平台数据, 将s3c_i2c0_cfg_gpio
函数设置到了平台数据cfg_gpio
方法中,最后将平台数据挂接到s3c_device_i2c0
这个设备上。
1 | void s3c_i2c0_cfg_gpio(struct platform_device *dev) |
可以看到s3c_i2c0_cfg_gpio
函数只是对I2C控制器两根通信线的GPIO初始化。
接下去回到I2C总线驱动层i2c-s3c2410.c
中, 进入到s3c24xx_i2c_probe
函数进行分析。 probe函数的代码比较多,分段进行分析。
1 | struct s3c24xx_i2c *i2c; |
三星采用struct s3c24xx_i2c
结构体来对SoC的控制器进行抽象,该结构体继承于struct i2c_adapter
。该段代码先是从device中获取了平台数据,该平台数据即是上文调用s3c_i2c0_set_platdata
函数时设置的。然后对i2c->adap
进行了相关赋值,关键部分是i2c->adap.algo = &s3c24xx_i2c_algorithm;
,adap.algo
表示I2C主机控制器的操作方法,将该SoC的操作方法挂接到了适配器上。s3c24xx_i2c_algorithm
定义了两个操作方法,主要是master_xfer
方法,用来发送消息。代码如下。
1 | static const struct i2c_algorithm s3c24xx_i2c_algorithm = { |
s3c24xx_i2c_xfer
涉及到对具体控制器的操作,不进行展开,但是注意的是其内部调用的是s3c24xx_i2c_doxfer
,在s3c24xx_i2c_doxfer
函数内部发送完数据后,调用wait_event_timeout
函数来进行睡眠等待从机响应。因此可知内核中I2C的等待从机的ACK信号是通过中断实现的,即主机发送完数据后进入睡眠等待从机,从机响应后通过中断通知主机后唤醒。
probe函数接着做了获取时钟和使能时钟,相关代码如下。
1 | // 获取时钟 |
紧接着对具体IO和IRQ进行操作。
1 | // 获取I2C平台资源(IO内存地址、IRQ) |
把关注点放在初始化I2C控制器的s3c24xx_i2c_init
函数和申请IRQ上。
1 | static int s3c24xx_i2c_init(struct s3c24xx_i2c *i2c) |
可以看到设置I2C对应的管脚是调用平台数据中的cfg_gpio
,其实看到这里如果还有印象的话就能反应出来这是在调用s3c_i2c0_set_platdata
中设置的。该函数还设置了I2C控制器的从地址,该地址用来在控制器作为从地址时使用,但是这种情况的出现微乎其微。除此之外使能Tx/Rx Interrupt和ACK信号,配置了I2C的时钟频率。
注意从前一段分析中得知,内核中I2C采用中断方式等待从机响应,所以probe函数这一段代码中申请了IRQ并绑定了中断处理函数s3c24xx_i2c_irq
。
1 | static irqreturn_t s3c24xx_i2c_irq(int irqno, void *dev_id) |
具体也不展开分析了,但是要注意的是有这么一条线:该中断处理函数调用了i2c_s3c_irq_nextbyte
,然后内部调用了s3c24xx_i2c_stop
,再内部调用了s3c24xx_i2c_master_complete
,最后再内部执行了一个关键代码wake_up(&i2c->wait);
,这就是通过中断方式唤醒之前在发送数据时进行的睡眠等待。
回到probe函数,最后分析重头戏。
1 | ret = i2c_add_numbered_adapter(&i2c->adap); |
该代码将I2C适配器注册到了内核中。i2c_add_numbered_adapter
函数由核心层提供,其定义位于I2C核心层drivers/i2c/i2c-core.c
中,用来注册I2C适配器。其实在内核中提供了两个adapter注册接口,分别为i2c_add_adapter
和i2c_add_numbered_adapter
由于在系统中可能存在多个adapter, 所以将每一条I2C总线(控制器)对应一个编号,这个总线号(可以称这个编号为总线号码)与PCI中的总线号不同。它和硬件无关, 只是软件上便于区分而已。对于i2c_add_adapter
而言, 它使用的是动态总线号, 即由系统给其分配一个总线号, 而i2c_add_numbered_adapter
则是自己指定总线号, 如果这个总线号非法或者是被占用, 就会注册失败。不管哪个注册接口,其核心都是调用i2c_register_adapter
函数来进行真正的注册。取出i2c_register_adapter
函数的关键部分进行分析。
1 | res = device_register(&adap->dev); |
device_register(&adap->dev);
表示主机适配器adapter的注册。
i2c_scan_static_board_info(adap);
内部先遍历__i2c_board_list
取出板卡信息(描述的是板子上的I2C外设的信息,即I2C从机的信息),该链表的生成是在arch/arm/mach-s5pv210/mach-x210.c
中进行的,在mach-x210.c
中的smdkc110_machine_init
函数中进行了除之前分析的调用s3c_i2c0_set_platdata
外,还调用了i2c_register_board_info
对板卡信息进行了注册。
1 | int __init |
板卡信息的描述,主要对其设备名和从地址进行赋值,示例如下
1 |
|
然后在i2c_scan_static_board_info
内部利用板卡信息作为原料调用i2c_new_device
来创建了client,表示从机设备,并将adapter挂接到了client结构体内部的指针上。i2c_scan_static_board_info
代码如下。
1 | static void i2c_scan_static_board_info(struct i2c_adapter *adapter) |
创建完client后,回到i2c_register_adapter
函数,最后执行了dummy = bus_for_each_drv(&i2c_bus_type, NULL, adap, __process_new_adapter);
,该函数是遍历在I2C总线上已经注册的driver,通过回调__process_new_adapter
函数的方式,遍历到i2c-dev这个通用驱动后就会用其i2cdev_attach_adapter
方法来挂接到在i2c-dev中注册的字符设备驱动,并使用这个字符设备驱动的主设备号和adapter中的总线号(作为次设备号)来创建名为i2c-x的设备节点,应用层访问这个设备节点后即可调用在i2c-dev中注册的file_operations中的操作方法,从操作方法源码知,最终读写调用的是adapter中的读写方法(即在本平台中为i2c-s3c2410.c中定义的方法)。下面对其进行验证。
__process_new_adapter
展开如下
1 | static int i2c_do_add_adapter(struct i2c_driver *driver, |
可以看到driver->attach_adapter(adap);
,的确是调用I2C总线下的驱动中的attach_adapter
方法,到了这里在I2C设备驱动层埋下的悬念终于要水落石出了(不容易啊啊啊啊啊啊),穿越回到I2C设备驱动层进行分析,进入drivers/i2c/i2c-dev.c
分析i2cdev_attach_adapter
方法。
1 | static int i2cdev_attach_adapter(struct i2c_adapter *adap) |
i2c_dev->dev = device_create(i2c_dev_class, &adap->dev, MKDEV(I2C_MAJOR, adap->nr), NULL, "i2c-%d", adap->nr);
使用主设备号和adapter中的总线号(作为次设备号)来创建名为i2c-x的设备节点。
1 | static ssize_t i2cdev_write(struct file *file, const char __user *buf, |
以write函数为例,可以看到写数据通过ret = i2c_master_send(client, tmp, count);
完成的。
1 | int i2c_master_send(struct i2c_client *client, const char *buf, int count) |
可以看到,经过I2C数据包的封装后,真正的最终写数据通过ret = i2c_transfer(adap, &msg, 1);
完成的。进入到i2c_transfer
函数,截取关键部分。
1 | for (ret = 0, try = 0; try <= adap->retries; try++) { |
山回路转不见君,雪上空留马行处。
adap->algo->master_xfer(adap, msgs, num);
终于回到了原点见到了I2C总线驱动层中定义的操作方法。
可以看到过程的确如上文所说,表现为从I2C总线驱动层自底向上后又由自顶向下的调用流程,简直一跃千里后又倾泻而下。
笔者以S5PV210的E2PROM驱动为例讲解, 源码见github链接。
1 | struct e2prom_device { |
封装一个e2prom_device
结构体表示对E2PROM的抽象,其中包含I2C client(用来表示I2C从设备)以及class和device(这两者单纯是用来自动创建设备节点的)。
1 | struct i2c_device_id e2prom_table[] = { |
先是调用i2c_add_driver
注册I2C设备驱动。根据上文在I2C核心层的源码分析可知,会通过在核心层中注册的i2c_bus_type
下的i2c_device_match
函数来匹配设备与驱动,一旦匹配上则会调用其i2c_device_probe
函数,而i2c_device_probe
函数又会调用i2c_driver的probe函数。注意如上文分析所知,client生成的原料为board_info,所以要使这个驱动成功匹配,需要在arch/arm/mach-s5pv210/mach-x210.c
中使用i2c_register_board_info
来注册board_info。接下去直奔prob函数进行分析。
1 | struct file_operations e2prom_fops = { |
在probe函数中调用register_chrdev
函数来将E2PROM驱动注册为了字符设备驱动,并绑定了fops。然后调用class_create
和device_create
自动生成设备节点。
1 | static int e2prom_open(struct inode *inode, struct file *file) |
open方法为空,以write方法为例讲解具体的操作,read方法类似。
1 | static ssize_t e2prom_write(struct file *file, const char __user *buf, |
可以看到真正的操作I2C在i2c_write_byte
函数。
1 | static int i2c_write_byte(char *buf, int count) |
可以看到是调用在I2C核心层提供的传输函数,其本质还是在传输函数内部调用了跟具体SoC相关的I2C主机控制器操作方法中的传输方法。该函数接口需要提供一个i2c_msg
,所以对其进行了创建并填充,注意msg.flags = 0;
中0表示写,1表示读。
终了,撒花!!!✿✿✿ ~
]]>os.system
函数与系统编程中的exec族函数调用一致,创建出子进程后代码段由外部程序替换,不会返回外部程序运行结果。
1 | import os |
os.popen
返回的是一个文件对象,它将外部程序运行结果保存在文件对象中,当调用其read
方法时就会得到运行结果。该方法可以得到外部程序的运行结果。
1 | import os |
commands模块只能在Python2中使用,Python3将其移除了。commands.getoutput
方法直接将外部程序的输出结果作为字符串返回了。
1 | import commands |
Python3中引入的模块,在Python3中推荐使用该模块。subprocess.call
会将外部程序的输出结果输出并返回状态码。
1 | from subprocess improt call |
1 | import os |
1 | import os |
1 | import shutil |
1 | import shutil |
1 | import os |
]]>注: 路径相关操作在
os.path
模块中。命令行参数在sys
模块中,sys.argc
为参数个数,sys.argv
为参数列表,其中sys.argv[0]
为程序本身
输入子系统由三部分组成:
事件驱动层负责处理和应用程序的接口,向应用程序提供简单的、统一的事件接口。
设备驱动层负责与底层输入设备的通信。
输入核心层负责各个结构体的注册以及事件驱动层与设备驱动层的数据传递。
事件驱动层是内核提供的,对所有输入类设备都是通用的,内核里已经支持所有的事件驱动。而驱动开发则只需针对具体输入设备实现设备驱动。
都定义在include/linux/input.h
中。
struct device
input_dev 代表底层的输入设备,比如按键或鼠标,所有输入设备的input_dev对象保存在一个全局的input_dev链表里。
input_handler 代表某个输入设备的处理方法,比如evdev就是专门处理输入设备产生的事件,所有的input_handler对象保存在一个全局的input_handler链表里。
一个input_dev可以有多个input_handler,比如鼠标可以由evdev和mousedev来处理它产生的输入;同样,一个input_handler可以用于多种输入设备的事件处理。由于这种多对多关系的存在,所以需要将input_dev和input_handler关联起来,而input_handle就是用来关联两者的。每个input_handle都会产生一个设备文件节点,比如/dev/input 下的四个文件event0~3。通过input_handle就可以找到对应的input_dev和input_handler。
笔者会大体上对input子系统的源码进行分析,如若分析的有出入,还望指出。在分析之前,以一张input整体架构图来呈现整个输入设备到用户空间的数据传递。
内核在事件驱动层中实现了一个输入设备通用的事件驱动,即evdev,其实现在driver/input/evdev.c
中。无论是按键、触摸屏还是鼠标,都会通过evdev进行输入事件的处理。比如鼠标,如果用户空间读取的是evdev提供的设备节点,则上报的是一个未经处理的通用于所有输入设备的事件,而mousedev则会对输入事件进行处理从而上报的是鼠标特有的事件。笔者从evdev.c入手分析。
1 | static int __init evdev_init(void) |
通过调用input_register_handler
函数进行了evdev_handler
的注册。evdev_handler
是struct input_handler
的实例对象。
1 | static const struct input_device_id evdev_ids[] = { |
evdev_handler中描述了一些输入的处理函数以及与设备匹配用的id_table
,在接下去的源码里会使用到。
现在进到input_register_handler
函数里进行分析,以下是该函数所有源码,接下去会拆开分析。
input_register_handler函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38 int input_register_handler(struct input_handler *handler)
{
struct input_dev *dev;
int retval;
retval = mutex_lock_interruptible(&input_mutex);
if (retval)
return retval;
INIT_LIST_HEAD(&handler->h_list);
if (handler->fops != NULL) {
// 每个事件驱动所支持的次设备号范围是[32 * n, 32 * n + 32)
// 所以需要除于32来得到在input_table中的索引
if (input_table[handler->minor >> 5]) {
// 重复注册,错误
retval = -EBUSY;
goto out;
}
// 将handler放入input_table
input_table[handler->minor >> 5] = handler;
}
// 将handler放入input_handler_list链表中,表示注册了该handler
list_add_tail(&handler->node, &input_handler_list);
// 遍历已经注册的设备,匹配device和handler,
// 匹配成功则调用handler->connect函数将device和handler关联成handle,
// 然后进行设备的注册
list_for_each_entry(dev, &input_dev_list, node)
input_attach_handler(dev, handler);
input_wakeup_procfs_readers();
out:
mutex_unlock(&input_mutex);
return retval;
}
input_register_handler
函数定义在input.c中,即现在进入到了输入核心层。
1 | if (handler->fops != NULL) { |
从evdev_handler
的定义中可以看到handler->fops
是有定义的,所以进入到子语句。这里解释一下handler->minor >> 5
,内核中对每个事件驱动所支持的次设备号范围规定是[32 * n, 32 * n + 32),比如mousedev的次设备号范围是[32, 64)、evdev的次设备号范围是[64, 96)等,相当于将次设备号以32个为一组对各种事件驱动进行了分类。该段代码就是找到正确的位置将handler放入input_table中,然后将handler放入input_handler_list链表中,表示注册了该handler。
1 | list_for_each_entry(dev, &input_dev_list, node) |
这段代码是在遍历已经注册的设备,在input_attach_handler
函数里匹配device和handler,匹配成功则调用handler->connect
函数将device和handler关联成handle,然后进行设备的注册,然后input_register_handler
函数基本上执行完毕。
进到input_attach_handler
函数里进行分析。
input_attach_handler函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 static int input_attach_handler(struct input_dev *dev, struct input_handler *handler)
{
const struct input_device_id *id;
int error;
// 匹配input_handler和input_dev
id = input_match_device(handler, dev);
if (!id)
return -ENODEV;
// 匹配成功后,调用handler->connect将input_handler和input_dev绑定成input_handle
error = handler->connect(handler, dev, id);
if (error && error != -ENODEV)
printk(KERN_ERR
"input: failed to attach handler %s to device %s, "
"error: %d\n",
handler->name, kobject_name(&dev->dev.kobj), error);
return error;
}
从代码中可以看出先是匹配input_handler
和input_dev
,匹配成功后则调用connect函数进行连接。
input_match_device
函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43 static const struct input_device_id *input_match_device(struct input_handler *handler, struct input_dev *dev)
{
const struct input_device_id *id;
int i;
// 遍历input_hanlder的id_table,匹配input_dev
for (id = handler->id_table; id->flags || id->driver_info; id++) {
/* 严格匹配bus、vendor、product、version,前提是flag中有定义 */
if (id->flags & INPUT_DEVICE_ID_MATCH_BUS)
if (id->bustype != dev->id.bustype)
continue;
if (id->flags & INPUT_DEVICE_ID_MATCH_VENDOR)
if (id->vendor != dev->id.vendor)
continue;
if (id->flags & INPUT_DEVICE_ID_MATCH_PRODUCT)
if (id->product != dev->id.product)
continue;
if (id->flags & INPUT_DEVICE_ID_MATCH_VERSION)
if (id->version != dev->id.version)
continue;
// 严格匹配所有事件类型
MATCH_BIT(evbit, EV_MAX);
/* 严格匹配所有事件的子事件 */
MATCH_BIT(keybit, KEY_MAX);
MATCH_BIT(relbit, REL_MAX);
MATCH_BIT(absbit, ABS_MAX);
MATCH_BIT(mscbit, MSC_MAX);
MATCH_BIT(ledbit, LED_MAX);
MATCH_BIT(sndbit, SND_MAX);
MATCH_BIT(ffbit, FF_MAX);
MATCH_BIT(swbit, SW_MAX);
// 如果有定义handler->match函数,再调用handler->match进行匹配
if (!handler->match || handler->match(handler, dev))
return id;
}
return NULL;
}
从evdev_handler
中的id_table
的定义可以知道并没有定义任何flag和bit,所以这些严格匹配在evdev中都不会进行。而且handler->match
为NULL,所以对于evdev而言这个函数并没有做什么,而是直接将id返回了。
回到input_attach_handler
函数,最后在匹配成功后调用了handler->connect
函数。这个connect函数实现在事件驱动层,所以回到evdev.c。
evdev_connect
函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63 static int evdev_connect(struct input_handler *handler, struct input_dev *dev,const struct input_device_id *id)
{
struct evdev *evdev;
int minor;
int error;
// 找到一个evdev_table中未使用的索引
for (minor = 0; minor < EVDEV_MINORS; minor++)
if (!evdev_table[minor])
break;
if (minor == EVDEV_MINORS) {
printk(KERN_ERR "evdev: no more free evdev devices\n");
return -ENFILE;
}
evdev = kzalloc(sizeof(struct evdev), GFP_KERNEL);
if (!evdev)
return -ENOMEM;
INIT_LIST_HEAD(&evdev->client_list);
spin_lock_init(&evdev->client_lock);
mutex_init(&evdev->mutex);
init_waitqueue_head(&evdev->wait);
/* 初始化struct evdev */
dev_set_name(&evdev->dev, "event%d", minor);
evdev->exist = 1;
evdev->minor = minor;
evdev->handle.dev = input_get_device(dev);
evdev->handle.name = dev_name(&evdev->dev);
evdev->handle.handler = handler;
evdev->handle.private = evdev;
// EVDEV_MINOR_BASE + minor 生成真正的次设备号
evdev->dev.devt = MKDEV(INPUT_MAJOR, EVDEV_MINOR_BASE + minor);
evdev->dev.class = &input_class;
evdev->dev.parent = &dev->dev;
evdev->dev.release = evdev_free;
device_initialize(&evdev->dev);
error = input_register_handle(&evdev->handle); // 注册input_handle
if (error)
goto err_free_evdev;
error = evdev_install_chrdev(evdev);
if (error)
goto err_unregister_handle;
error = device_add(&evdev->dev); // 注册设备,创建设备节点
if (error)
goto err_cleanup_evdev;
return 0;
err_cleanup_evdev:
evdev_cleanup(evdev);
err_unregister_handle:
input_unregister_handle(&evdev->handle);
err_free_evdev:
put_device(&evdev->dev);
return error;
}
实例化了一个struct evdev
对象,该结构体是对一个完整的evdev事件驱动的抽象描述。初始化struct evdev
,将input_handler
和input_dev
关联起来形成input_handle
,然后赋给evdev->handle
,生成设备的设备号,向内核注册input_handle
,最后注册设备以及创建设备节点。至此evdev的注册就结束了。
以usbmouse.c为例分析鼠标的设备驱动,鼠标是挂载在usb总线下,笔者在这里将usb相关的代码忽略,只关心输入子系统有关的代码。根据Linux设备模型的原理,直接进入到usb_mouse_probe
函数进行分析。
1 | struct input_dev *input_dev; |
先实例化一个struct input_dev
对象,然后进行相关初始化,struct input_dev
成员中定义了一些位图,如下
1 | unsigned long evbit[BITS_TO_LONGS(EV_CNT)]; // 描述设备所支持的事件类型 |
所以在初始化中还对evbit
、keybit
等成员进行了初始化,表示鼠标所支持的事件类型。最后调用input_register_device
函数完成了鼠标设备的注册。
在usb_mouse_irq
函数中进行事件的上报。
1 | // data[0] & 0x01 取出最后一位,1表示按下,0表示未按下 |
关键部分就是调用input_report_key
函数来上报按键信息,调用input_report_rel
上报鼠标的相对位移,最后调用input_sync
来提交同步事件,告知input子系统,该设备已经提交了一个完整报告。
设备驱动通过一系列input_report_xxx函数来上报事件,以input_report_key
函数为例进行分析。
1 | // 提交按键事件 |
input_report_key
函数调用的input_event
函数,其实一系列上报函数(包括input_sync
函数)都是调用的input_event
函数。
1 | void input_event(struct input_dev *dev, |
关键就是调用了input_handle_event
函数,而input_handle_event
函数中的关键就是下面代码
1 | if (disposition & INPUT_PASS_TO_HANDLERS) |
进到input_pass_event
函数
1 | static void input_pass_event(struct input_dev *dev, |
可以看到核心就是调用handler->event
函数,以evdev为例,回到evdev.c中,进入到evdev_event
函数中
1 | client = rcu_dereference(evdev->grab); |
需要关心的部分是从evdev对象中取出了client对象(两者的挂接是在open时完成的),然后执行了evdev_pass_event(client, &event);
(其中evdev
是struct evdev
的实例对象,是对一个完整的evdev事件驱动的抽象描述,其中struct evdev_client *grab
成员管理该事件驱动下的所有client
;client
是struct evdev_client
的实例对象,对于同一个设备,每打开一次就会实例化出一个该结构体的对象)
1 | static void evdev_pass_event(struct evdev_client *client, |
从上面代码可以看到将struct input_event
的实例对象存进了client中的buffer里。struct evdev_client
的定义如下
1 | // 每打开一次设备就会实例化出该结构体 |
所以到此就清晰了事件从底层设备如何传递到事件驱动层的,事件驱动层的cline->buffer
就是用来中转数据的,接下来我们关心事件是如何从事件驱动层传递给应用层。
以evdev.c为例,进入到handler中的fops中的open和read函数。
evdev_open
函数主要做的是根据次设备号减去基地址得到索引,从evdev_table
中取出evdev
对象,然后实例化出一个client
对象,将clinet
对象绑定到evdev
对象中。
evdev_read
函数的核心部分如下
1 | while (retval + input_event_size() <= count && |
evdev_fetch_next_event
函数从client->buffer
中取出从底层设备提交上来的事件赋给event
,然后调用input_event_to_user
函数将这个event
传递给从用户层传下来的buffer
,完成从事件驱动层到用户空间的数据传递。