Shiro原理-过滤器

前言

这几天一直在研究Shiro到底是如何工作的,即一个请求过来了,它是如何做到知道这个请求应该用什么方式来鉴权的?应该调用哪个过滤器?自己定义的过滤器该如何才能生效?

带着这样的疑问,我做了一些测试与研究,并记录于此文。

实现原理

Shiro对于请求的鉴权的实现也是通过过滤器(或者说是拦截器)来实现的,但是Spring项目中有拦截链机制,会有多个拦截器生效,包括系统内置的以及Shiro注入的,所以需要搞懂他的过滤的实现机制就需要去弄明白这些过滤器是如何过滤的。

那就开始吧

ApplicationFilterChain 简介

Tomcat的类ApplicationFilterChain是一个Java Servlet API规范javax.servlet.FilterChain的实现,用于管理某个请求request的一组过滤器Filter的执行。当针对一个request所定义的一组过滤器Filter处理完该请求后,最后一个doFilter()调用才会执行目标Servlet的方法service(),然后响应对象response会按照相反的顺序依次被这些Filter处理,最终到达客户端。

在ApplicationFilterChain的doFilter方法下打上断点

// org.apache.catalina.core.ApplicationFilterChain.java

// 执行过滤器链中的下一个过滤器Filter。如果链中所有过滤器都执行过,则调用servlet的service()方法。

public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {

// 这个if-else分支主要是根据Globals.IS_SECURITY_ENABLED是true还是false决定

// 如何调用目标逻辑,但两种情况下,目标逻辑最终都是 internalDoFilter(req,res)

if (Globals.IS_SECURITY_ENABLED) {

final ServletRequest req = request;

final ServletResponse res = response;

try {

AccessController.doPrivileged(new PrivilegedExceptionAction() {

public Void run() throws ServletException, IOException {

// 调用internalDoFilter

ApplicationFilterChain.this.internalDoFilter(req, res);

return null;

}

});

} catch (PrivilegedActionException var7) {

Exception e = var7.getException();

if (e instanceof ServletException) {

throw (ServletException)e;

}

if (e instanceof IOException) {

throw (IOException)e;

}

if (e instanceof RuntimeException) {

throw (RuntimeException)e;

}

throw new ServletException(e.getMessage(), e);

}

} else {

// 调用internalDoFilter

this.internalDoFilter(request, response);

}

}

filters

我们可以看到 filters中包含了5个过滤器:

CharacterEncodingFilter:spring内置过滤器,用来指定请求或者响应的编码格式。

FormContentFilter:该过滤器针对DELETE,PUT和PATCH这三种HTTP method分析其FORM表单参数,将其暴露为Servlet请求参数。

RequestContextFilter:该过滤器将当前请求暴露到当前线程。

SpringShiroFilter:shiro内置过滤器,包装 Request 和 Response,使它们由原来的 HttpServlet 系列包装为 ShiroHttpServletRequest等。

Tomcat WebSocket Filter:webSocket 相关过滤器。

这些注入的过滤器会通过internalDoFilter来执行过滤工作,如下:

// org.apache.catalina.core.ApplicationFilterChain.java

private void internalDoFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {

if (this.pos < this.n) {

// 如果过滤链中还有过滤器需要过滤

ApplicationFilterConfig filterConfig = this.filters[this.pos++];

try {

// 找到目标的Filter

Filter filter = filterConfig.getFilter();

if (request.isAsyncSupported() && "false".equalsIgnoreCase(filterConfig.getFilterDef().getAsyncSupported())) {

request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", Boolean.FALSE);

}

// 执行目标 Filter 对象的 doFilter方法,

// 注意,这里当前ApplicationFilterChain对象被传递到了目标

// Filter对象的doFilter方法,而目标Filter对象的doFilter在执行完自己

// 被指定的逻辑之后会反过来调用这个ApplicationFilterChain对象的

// doFilter方法,只是pos向前推进了一个过滤器。这个ApplicationFilterChain

// 和Filter之间反复调用彼此doFilter方法的过程一直持续直到当前链发现所有的

// Filter都已经被执行

if (Globals.IS_SECURITY_ENABLED) {

Principal principal = ((HttpServletRequest)request).getUserPrincipal();

Object[] args = new Object[]{request, response, this};

SecurityUtil.doAsPrivilege("doFilter", filter, classType, args, principal);

} else {

filter.doFilter(request, response, this);

}

} catch (ServletException | RuntimeException | IOException var15) {

throw var15;

} catch (Throwable var16) {

Throwable e = ExceptionUtils.unwrapInvocationTargetException(var16);

ExceptionUtils.handleThrowable(e);

throw new ServletException(sm.getString("filterChain.filter"), e);

}

} else {

// 调用servlet的service()方法

// We fell off the end of the chain -- call the servlet instance

// 这里是过滤器链中所有的过滤器都已经被执行的情况,现在需要调用servlet实例本身了。

// !!! 注意 : 虽然这里开始调用servlet实例了,但是从当前方法执行堆栈可以看出,过滤器链

// 和链中过滤器的doFilter方法的执行帧还在堆栈中并未退出,他们会在servlet实例的逻辑

// 执行完后,分别执行完自己剩余的的逻辑才会逐一结束。

try {

if (ApplicationDispatcher.WRAP_SAME_OBJECT) {

lastServicedRequest.set(request);

lastServicedResponse.set(response);

}

if (request.isAsyncSupported() && !this.servletSupportsAsync) {

request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", Boolean.FALSE);

}

if (request instanceof HttpServletRequest && response instanceof HttpServletResponse && Globals.IS_SECURITY_ENABLED) {

Principal principal = ((HttpServletRequest)request).getUserPrincipal();

Object[] args = new Object[]{request, response};

SecurityUtil.doAsPrivilege("service", this.servlet, classTypeUsedInService, args, principal);

} else {

this.servlet.service(request, response);

}

} catch (ServletException | RuntimeException | IOException var17) {

throw var17;

} catch (Throwable var18) {

Throwable e = ExceptionUtils.unwrapInvocationTargetException(var18);

ExceptionUtils.handleThrowable(e);

throw new ServletException(sm.getString("filterChain.servlet"), e);

} finally {

if (ApplicationDispatcher.WRAP_SAME_OBJECT) {

lastServicedRequest.set((Object)null);

lastServicedResponse.set((Object)null);

}

}

}

}

责任链原理图

Shiro Filter 注册

Shiro 中对请求的配置需要在Shiro的配置文件中配置的,可以在这里添加自定义的过滤器以及配置相关的URL过滤信息,而ShiroFilter是在ShiroFilterFactoryBean中创建的,所以我们首先需要配置注入好ShiroFilterFactoryBean。

@Bean("shiroFilterFactoryBean")

public ShiroFilterFactoryBean shiroFilter(DefaultWebSecurityManager securityManager) {

ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();

bean.setSecurityManager(securityManager);

// 自定义过滤器

Map filterMap = shiroFilterFactoryBean.getFilters();

filterMap.put("hasToken", accessTokenFilter());

shiroFilterFactoryBean.setFilters(filterMap);

/**

* anon:匿名用户可访问

* authc:认证用户可访问ShiroFilterFactoryBean

* user:使用rememberMe可访问

* perms:对应权限可访问

* role:对应角色权限可访问

**/

// URL的过滤

Map filterChainMap = new LinkedHashMap<>();

// 登录接口开放

filterChainMap.put("/auth/login", "anon");

// 获取用户信息需要认证用户

filterChainMap.put("/user/**", "authc");

...

bean.setFilterChainDefinitionMap(filterChainMap);

return bean;

}

Filter注入

默认过滤器

public enum DefaultFilter {

anon(AnonymousFilter.class),

authc(FormAuthenticationFilter.class),

authcBasic(BasicHttpAuthenticationFilter.class),

authcBearer(BearerHttpAuthenticationFilter.class),

logout(LogoutFilter.class),

noSessionCreation(NoSessionCreationFilter.class),

perms(PermissionsAuthorizationFilter.class),

port(PortFilter.class),

rest(HttpMethodPermissionFilter.class),

roles(RolesAuthorizationFilter.class),

ssl(SslFilter.class),

user(UserFilter.class);

...

}

Filter Name

Class

anon

org.apache.shiro.web.filter.authc.AnonymousFilter

authc

org.apache.shiro.web.filter.authc.FormAuthenticationFilter

authcBasic

org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter

logout

org.apache.shiro.web.filter.authc.LogoutFilter

noSessionCreation

org.apache.shiro.web.filter.session.NoSessionCreationFilter

perms

org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter

port

org.apache.shiro.web.filter.authz.PortFilter

rest

org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter

roles

org.apache.shiro.web.filter.authz.RolesAuthorizationFilter

ssl

org.apache.shiro.web.filter.authz.SslFilter

user

org.apache.shiro.web.filter.authc.UserFilter

// DefaultFilterChainManager

// 加载默认过滤器

protected void addDefaultFilters(boolean init) {

DefaultFilter[] var2 = DefaultFilter.values();

int var3 = var2.length;

for(int var4 = 0; var4 < var3; ++var4) {

DefaultFilter defaultFilter = var2[var4];

this.addFilter(defaultFilter.name(), defaultFilter.newInstance(), init, false);

}

}

// org.apache.shiro.spring.web.ShiroFilterFactoryBean

// 创建实体的方法中调用了加载过滤器链的方法createFilterChainManager

protected AbstractShiroFilter createInstance() throws Exception {

log.debug("Creating Shiro Filter instance.");

SecurityManager securityManager = this.getSecurityManager();

String msg;

if (securityManager == null) {

msg = "SecurityManager property must be set.";

throw new BeanInitializationException(msg);

} else if (!(securityManager instanceof WebSecurityManager)) {

msg = "The security manager does not implement the WebSecurityManager interface.";

throw new BeanInitializationException(msg);

} else {

FilterChainManager manager = this.createFilterChainManager();

PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();

chainResolver.setFilterChainManager(manager);

return new ShiroFilterFactoryBean.SpringShiroFilter((WebSecurityManager)securityManager, chainResolver);

}

}

// 匿名内部类 SpringShiroFilter

private static final class SpringShiroFilter extends AbstractShiroFilter {

protected SpringShiroFilter(WebSecurityManager webSecurityManager, FilterChainResolver resolver) {

if (webSecurityManager == null) {

throw new IllegalArgumentException("WebSecurityManager property cannot be null.");

} else {

this.setSecurityManager(webSecurityManager);

if (resolver != null) {

this.setFilterChainResolver(resolver);

}

}

}

}

// 创建过滤器链

protected FilterChainManager createFilterChainManager() {

// 创建DefaultFilterChainManager

DefaultFilterChainManager manager = new DefaultFilterChainManager();

// 先获取默认的过滤器

Map defaultFilters = manager.getFilters();

Iterator var3 = defaultFilters.values().iterator();

// 对每个默认的过滤器执行applyGlobalPropertiesIfNecessary方法

// applyGlobalPropertiesIfNecessary的作用:

// - 设置customAuthenticationFilter中的loginUrl,SuccessUrl和unauthorizedUrl。

while(var3.hasNext()) {

Filter filter = (Filter)var3.next();

this.applyGlobalPropertiesIfNecessary(filter);

}

Map filters = this.getFilters();

String name;

Filter filter;

// 加载自定义的过滤器,进行设置并添加到过滤器管理器DefaultFilterChainManager中

// 如果不为空的话也要执行applyGlobalPropertiesIfNecessary方法

if (!CollectionUtils.isEmpty(filters)) {

for(Iterator var10 = filters.entrySet().iterator(); var10.hasNext(); manager.addFilter(name, filter, false)) {

Entry entry = (Entry)var10.next();

name = (String)entry.getKey();

filter = (Filter)entry.getValue();

this.applyGlobalPropertiesIfNecessary(filter);

if (filter instanceof Nameable) {

((Nameable)filter).setName(name);

}

}

}

// 加载URL的过滤,并调用createChain方法构造过滤链

Map chains = this.getFilterChainDefinitionMap();

if (!CollectionUtils.isEmpty(chains)) {

Iterator var12 = chains.entrySet().iterator();

while(var12.hasNext()) {

Entry entry = (Entry)var12.next();

String url = (String)entry.getKey();

String chainDefinition = (String)entry.getValue();

manager.createChain(url, chainDefinition);

}

}

return manager;

}

// 构造过滤链,通过URL过滤规则

public void createChain(String chainName, String chainDefinition) {

if (!StringUtils.hasText(chainName)) {

throw new NullPointerException("chainName cannot be null or empty.");

} else if (!StringUtils.hasText(chainDefinition)) {

throw new NullPointerException("chainDefinition cannot be null or empty.");

} else {

if (log.isDebugEnabled()) {

log.debug("Creating chain [" + chainName + "] from String definition [" + chainDefinition + "]");

}

// 如果指定了多个过滤器

String[] filterTokens = this.splitChainDefinition(chainDefinition);

String[] var4 = filterTokens;

int var5 = filterTokens.length;

// 将所有过滤器加到过滤链中去

for(int var6 = 0; var6 < var5; ++var6) {

String token = var4[var6];

String[] nameConfigPair = this.toNameConfigPair(token);

this.addToChain(chainName, nameConfigPair[0], nameConfigPair[1]);

}

}

}

// 添加到过滤链中去

public void addToChain(String chainName, String filterName, String chainSpecificFilterConfig) {

if (!StringUtils.hasText(chainName)) {

throw new IllegalArgumentException("chainName cannot be null or empty.");

} else {

// 根据过滤器的名字找到过滤器

Filter filter = this.getFilter(filterName);

if (filter == null) {

// 如果不存在就抛出异常

throw new IllegalArgumentException("There is no filter with name '" + filterName + "' to apply to chain [" + chainName + "] in the pool of available Filters. Ensure a filter with that name/path has first been registered with the addFilter method(s).");

} else {

this.applyChainConfig(chainName, filter, chainSpecificFilterConfig);

NamedFilterList chain = this.ensureChain(chainName);

chain.add(filter);

}

}

}

Shiro Filter 匹配

刚刚我们分析Spring Shiro的Shiro注入的时候,我们可以看到createInstance方法返回的是AbstractShiroFilter的子类SpringShiroFilter,而AbstractShiroFilter也是OncePerRequestFilter的子类,我们可以看看继承图:

SpringShiroFilter继承图

我们来看看AbstractShiroFilter的部分源码:

// org.apache.shiro.web.servlet.AbstractShiroFilter.java

// OncePerRequestFilter 执行 doFilter 方法时调用了该方法

protected void doFilterInternal(ServletRequest servletRequest, ServletResponse servletResponse, final FilterChain chain) throws ServletException, IOException {

Throwable t = null;

try {

final ServletRequest request = this.prepareServletRequest(servletRequest, servletResponse, chain);

final ServletResponse response = this.prepareServletResponse(request, servletResponse, chain);

Subject subject = this.createSubject(request, response);

// 执行该方法自动将subject绑定到线程的subject中

subject.execute(new Callable() {

public Object call() throws Exception {

// 更新session相关信息

AbstractShiroFilter.this.updateSessionLastAccessTime(request, response);

// 执行过滤链

AbstractShiroFilter.this.executeChain(request, response, chain);

return null;

}

});

} catch (ExecutionException var8) {

t = var8.getCause();

} catch (Throwable var9) {

t = var9;

}

if (t != null) {

if (t instanceof ServletException) {

throw (ServletException)t;

} else if (t instanceof IOException) {

throw (IOException)t;

} else {

String msg = "Filtered request failed.";

throw new ServletException(msg, t);

}

}

}

// 执行过滤链,

protected void executeChain(ServletRequest request, ServletResponse response, FilterChain origChain) throws IOException, ServletException {

// 获取需要执行的过滤链

FilterChain chain = this.getExecutionChain(request, response, origChain);

chain.doFilter(request, response);

}

// 获取需要执行的过滤链

protected FilterChain getExecutionChain(ServletRequest request, ServletResponse response, FilterChain origChain) {

FilterChain chain = origChain;

// 获取解析器,这里获取到的是 PathMatchingFilterChainResolver

FilterChainResolver resolver = this.getFilterChainResolver();

if (resolver == null) {

log.debug("No FilterChainResolver configured. Returning original FilterChain.");

return origChain;

} else {

// 获取过滤链 就是在这里通过请求的url选择了相应的过滤链的

FilterChain resolved = resolver.getChain(request, response, origChain);

if (resolved != null) {

log.trace("Resolved a configured FilterChain for the current request.");

chain = resolved;

} else {

log.trace("No FilterChain configured for the current request. Using the default.");

}

return chain;

}

}

// org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver

public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {

// 获取Manager

FilterChainManager filterChainManager = this.getFilterChainManager();

if (!filterChainManager.hasChains()) {

return null;

} else {

// 接下来就是先获取请求的url,然后去匹配过滤链,然后再返回。

String requestURI = this.getPathWithinApplication(request);

if (requestURI != null && !"/".equals(requestURI) && requestURI.endsWith("/")) {

requestURI = requestURI.substring(0, requestURI.length() - 1);

}

Iterator var6 = filterChainManager.getChainNames().iterator();

// 遍历匹配

String pathPattern;

do {

if (!var6.hasNext()) {

return null;

}

pathPattern = (String)var6.next();

if (pathPattern != null && !"/".equals(pathPattern) && pathPattern.endsWith("/")) {

pathPattern = pathPattern.substring(0, pathPattern.length() - 1);

}

} while(!this.pathMatches(pathPattern, requestURI));

if (log.isTraceEnabled()) {

log.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + Encode.forHtml(requestURI) + "]. Utilizing corresponding filter chain...");

}

return filterChainManager.proxy(originalChain, pathPattern);

}

}

PathMatchingFilterChainResolver

返回的是一个ProxiedFilterChain的实例,该实例包含了PathMatchingFilterChainResolver所匹配出来的过滤器,如下图,匹配的是系统内置的名为anon过滤器,至于它所对应的过滤器是什么可以见上面默认过滤器的表。

getExecutionChain结果

接着,在AbstractShiroFilter中的executeChain就会执行它的doFilter方法。

// org.apache.shiro.web.servlet.ProxiedFilterChain

public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {

// 执行所代理的过滤器的doFilter方法。

if (this.filters != null && this.filters.size() != this.index) {

if (log.isTraceEnabled()) {

log.trace("Invoking wrapped filter at index [" + this.index + "]");

}

((Filter)this.filters.get(this.index++)).doFilter(request, response, this);

} else {

if (log.isTraceEnabled()) {

log.trace("Invoking original filter chain.");

}

this.orig.doFilter(request, response);

}

}

总结

刚开始学习看源码,IDEA的调试工具也不是很会用,好在确实IDEA很强大,不然这么多过滤链跳来跳去是真的难懂。经过这次的阅读以及网上的博客的研究,Shiro的过滤链如果有自定义的过滤链的话,一定不能像平常的拦截器那样注入,必须要在注入ShiroFilterFactoryBean时使用如下方式注入才能生效。

Map filterMap = new LinkedHashMap<>();

filterMap.put("jwt",new CustomAuthenticationFilter());

bean.setFilters(filterMap);

bean.setSecurityManager(securityManager);

因为很可能像平常注入过滤器那样注入先后顺序可能会存在问题。

authc过滤器 shiro_shiro原理之过滤器相关推荐

  1. 安全认证框架Shiro (二)- shiro过滤器工作原理

    安全认证框架Shiro (二)- shiro过滤器工作原理 安全认证框架Shiro 二- shiro过滤器工作原理 第一前言 第二ShiroFilterFactoryBean入口 第三请求到来解析过程 ...

  2. 算法:详解布隆过滤器的原理、使用场景和注意事项@知乎.Young Chen

    算法:详解布隆过滤器的原理.使用场景和注意事项@知乎.Young Chen 什么是布隆过滤器 本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data struc ...

  3. 详解布隆过滤器的原理、使用场景和注意事项

    在进入正文之前,之前看到的有句话我觉得说得很好: Data structures are nothing different. They are like the bookshelves of you ...

  4. 布隆过滤器速度_详解布隆过滤器的原理、使用场景和注意事项

    今天碰到个业务,他的 Redis 集群有个大 Value 用途是作为布隆过滤器,但沟通的时候被小怼了一下,意思大概是 "布隆过滤器原理都不懂,还要我优化?".技术菜被人怼认了.怪不 ...

  5. bloomfilter的java实现,BloomFilter(布隆过滤器)原理及实战详解

    什么是 BloomFilter(布隆过滤器) 布隆过滤器(英语:Bloom Filter)是 1970 年由布隆提出的.它实际上是一个很长的二进制向量和一系列随机映射函数.主要用于判断一个元素是否在一 ...

  6. 布隆过滤器的原理、应用场景和源码分析实现

    原理 布隆过滤器数据结构 布隆过滤器是一个 bit 向量或者说 bit 数组,长这样: 如果我们要映射一个值到布隆过滤器中,我们需要使用多个不同的哈希函数生成多个哈希值,并对每个生成的哈希值指向的 b ...

  7. Servlet→对象监听器、事件监听器、Session钝化活化、@WebListener标注、过滤器概念原理生命周期、过滤器链、@WebFilter标注、定时器Timer、cancel()、purge

    监听器ServletContextListener HttpSessionListener ServletRequestListener 事件监听器 Session钝化活化 @WebListener标 ...

  8. 全自动过滤器:全自动叠片过滤器工作原理及应用范围

    在一些发达国家,全自动叠片过滤器的使用已经相当普遍,因其能够在十分严苛的条件下精确稳定工作且不需大量维护工作而被用于市政供水.自来水厂.废水处理.化工企业以及其它应急情况过滤等.该过滤器采用模块化设计 ...

  9. 浅层砂过滤器的原理是什么,滤料是什么,需要不需要定期?

    浅层砂过滤器的原理是什么,滤料是什么,需要不需要定期 浅层砂过滤器的原理是什么? 高效连续过滤器的运行可分为原水过滤和滤料清洗,再进行两个相对独立又同时进行的过程.二者在同一个过滤器的不同位置完成,前 ...

  10. 【Flink】需求实现之独立访客数量的计算 和 布隆过滤器的原理及使用

    文章目录 一 独立访客数量计算 二 布隆过滤器 1 什么是布隆过滤器 2 实现原理 (1)HashMap 的问题 (2)布隆过滤器数据结构 3 使用布隆过滤器去重 一 独立访客数量计算 public ...

最新文章

  1. 内存分配管理 自定义
  2. Web登录很简单?开玩笑!
  3. python编程语法-Python基础及语法(十三)
  4. 字符串从右截取_跟运维组学Python基础day04(字符串str的索引和切片)
  5. Codeforses 185 A Plant 思维 规律
  6. 程序员要常做好的几件事
  7. 二分(三分)+快速幂
  8. P2550 [AHOI2001]彩票摇奖(python3实现)
  9. 图论 —— 生成树 —— 最小生成树 —— Kruskal
  10. hdu 5616 Jam's balance(dp 正反01背包)
  11. carto笔记--- 传感器数据走向
  12. Nod32的内网升级方案
  13. (附源码)ssm天天超市购物网站 毕业设计 022101
  14. ISIS协议的有关认识
  15. 计算机提示无法识别优盘,U盘插入电脑提示无法识别的解决方法
  16. 电脑c盘满了变成红色了怎么清理?看看这7个方法
  17. 采样频率和带宽的关系_示波器关键参数---带宽
  18. python输入一个英文句子、统计单词个数_C语言编程求一个英文句子中的单词数和最长单词的位置、长度及输出这个单词。c++编程 从键盘输入一个英文...
  19. Jetson Nano--YoLoV5测试运行--记录
  20. 大数据告诉你何时何地买手机最划算!

热门文章

  1. 深富策略:“石化双雄”爆发 市场不确定性增大
  2. 微信公众号前端40163解决办法
  3. 数字系统设计中形式验证
  4. inpur标签的各种type
  5. App Store打了这么多年,ASO优化还剩什么?
  6. 项目:任务清单(Vuex)
  7. 企业微信客户端API分享微信朋友圈使用过程及总结
  8. java: 找不到符号 报错
  9. 基于uA741 PWM发生器
  10. linux ov7725模块驱动,stm32f4 驱动ov7725摄像头,使用dcmi一直无法产生中断