Giter Site home page Giter Site logo

imuncle.github.io's Introduction

Hi there, I'm big_uncle ! 👋

website Email bilibili


About

  • 🔭 I’m currently working on Computer Vision, Motion Planning, SLAM...
  • 😄 Hobbies: Articles & Music 🎧
  • ⚡ Stay hungry, Stay foolish ❤️
  • 🌱 I’m currently learning at CUHK

github stats

imuncle.github.io's People

Contributors

imuncle avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

imuncle.github.io's Issues

Electron学习(二)

在窗口中嵌入Web页面

使用webview标签可以在窗口中创造另外一个页面。不像 iframe,webview 在与应用程序不同的进程中运行,它与你的网页没有相同的权限,应用程序和嵌入内容之间的所有交互都将是异步的。

<webview id="webview" src="child.html" style="width:400px; height:300px" ></webview>

相应的页面事件

webview标签支持很多事件,例如,did-start-loading 可以监听页面正在装载事件,did-stop-loading 可以监听页面装载完成事件。webview标签使用事件的代码如下:

    onload = () => {
        const webview = document.getElementById('geekori');
        const loadstart = () => {
             console.log('loadstart');
        }
        const loadstop = () => {
            console.log('loadstop');
        }
        webview.addEventListener('did-start-loading', loadstart)
        webview.addEventListener('did-stop-loading', loadstop)
    }

装载的页面在默认情况下是不能调用 Node.js API 的,但添加 nodeintegration 属性后,页面就可以使用 Node.js API 了:

<webview id="other" src="./other1.html" style="width:400px; height:300px" nodeintegration></webview>

webview标签有很多方法,这里介绍一些常用的方法,代码如下:

webview = document.getElementById('web');
//装载新的页面
webview.loadURL('https://www.baidu.com');
//重新装载当前页面
webview.reload();
//获取当前页面的标题
console.log(webview.getTitle());
//获取当前页面对应的 URL
console.log(webview.getURL());
const title = webview.getTitle();
//在装载的页面执行 JavaScript 代码
webview.executeJavaScript('console.log("' + title + '");')
//打开调试工具
webview.openDevTools()

屏幕API

通过 screen 对象提供的方法,可以获得与屏幕相关的值:

const electron = require('electron')
const {app, BrowserWindow} = electron
const remote = electron.remote;
function onClick_Test() {
    const win = remote.getCurrentWindow();
    //  获取当前屏幕的宽度和高度(单位:像素)
    const {width, height} = electron.screen.getPrimaryDisplay().workAreaSize
    win.setSize(width,height,true)
    console.log('width:' + width);
    console.log('height:' + height);
   win.setPosition(0,0)
    //  获取鼠标的绝对坐标值
    console.log('x:' + electron.screen.getCursorScreenPoint().x)
    console.log('y:' + electron.screen.getCursorScreenPoint().y)
    console.log('菜单栏高度:' + electron.screen.getMenuBarHeight()) // Mac OS X
}

任务栏的进度条

通过 BrowserWindow.setProgressBar 方法可以在状态栏的应用程序图标上设置进度条,这个功能仅限于Windows,代码如下:

const remote = require('electron').remote;
function onClick_Test() {
    const win = remote.getCurrentWindow();
    win.setProgressBar(0.5)
}

创建菜单

Electron 桌面应用支持三种菜单:应用菜单上下文菜单托盘菜单

在 Electron 中,可以使用模板,也可以使用菜单对象来创建应用菜单。

应用菜单模板就是一个对象数组,每一个数据元素就是一个菜单项,可以通过数组中的对象设置这个菜单项的菜单文本及其他的属性,如菜单的子菜单。

下面就是一个典型的菜单模板的例子

const template = [{
    label: '文件',   //设置菜单项文本
    submenu: [    //设置子菜单
        {
            label: '关于',
            role: 'about',       // 设置菜单角色(关于),只针对 Mac  OS X 系统
            click: ()=>{     //设置单击菜单项的动作(弹出一个新的模态窗口)
                var aboutWin = new BrowserWindow({width:300,height:200,parent:win,modal: true});
                aboutWin.loadFile('https://www.baidu.com');}
        },
        {
            type: 'separator'       //设置菜单的类型是分隔栏
        },
        {
            label: '关闭',
            accelerator: 'Command+Q',      //设置菜单的热键
            click: ()=>{win.close()}
        }
    ]
},
    {
        label: '编辑',
        submenu: [
            {
                label: '复制',
                click: ()=>{win.webContents.insertText('复制')}

            },
            {
                label: '剪切',
                click: ()=>{win.webContents.insertText('剪切')}

            },
            {
                type: 'separator'
            },
            {
                label: '查找',
                accelerator: 'Command+F',
                click: ()=>{win.webContents.insertText('查找')}
            },
            {
                label: '替换',
                accelerator: 'Command+R',
                click: ()=>{win.webContents.insertText('替换')}
            }
        ]
    }
];

创建应用菜单需要 Menu 类,因此现在来编写使用菜单模板的代码:

const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;
const Menu  = electron.Menu;

function createWindow () {

    win = new BrowserWindow({file: 'index.html'});

    win.loadFile('./index.html');
    const template = ... //  定义菜单模板
    //  创建菜单对象
    const menu = Menu.buildFromTemplate(template);
    //  安装应用
    Menu.setApplicationMenu(menu);

    win.on('closed', () => {
      console.log('closed');

      win = null;
    })

  }

app.on('ready', createWindow)

app.on('activate', () => {

    if (win === null) {
        createWindow();
    }
})

菜单项的角色(role)

菜单项的角色就是菜单的预定义动作,通过菜单对象的 role 属性设置,通用的角色如下:

  • undo
  • redo
  • cut
  • copy
  • paste
  • pasteAndMatchStyle
  • selectAll
  • delete
  • minimize,最小化当前窗口
  • close,关闭当前窗
  • quit,退出应用程序
  • reload,重新装载当前窗口
  • forceReload,重新装载当前窗口(不考虑缓存)
  • toggleDevTools,在当前窗口显示开发者工具
  • toggleFullScreen,全屏显示当前窗口
  • resetZoom,重新设置当前页面的尺寸为最初的尺寸
  • zoomIn,将当前页面放大 10%
  • zoomOut,将当前页面缩小 10%
  • editMenu,整个“Edit”菜单,包括 Undo、Copy 等
  • windowMenu,整个“Window”菜单,包括 Minimize、Close 等
{
    label: '撤销',
    role:'undo'
}

菜单项的类型(type)

菜单项的类型通过 type 属性设置,该属性可以设置的值及其含义如下。

  • normal:默认菜单项
  • separator:菜单项分隔条
  • submenu:子菜单
  • checkbox:多选菜单项
  • radio:单选菜单项

为菜单项添加图标

通过设置菜单项的 icon 属性,可以为菜单项添加图标(显示在菜单项文字的前方)。在 Windows 中,建议使用 ico 图标文件,在 Mac OS X 和 Linux 下,一般使用 png 图像。菜单项图标的标准尺寸是 16 × 16,图标尺寸太大时,Electron 是不会压缩图像尺寸的,图标都会按原始尺寸显示。

const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;
const Menu  = electron.Menu;
function createWindow () {
    win = new BrowserWindow({file: 'index.html'});
    win.loadFile('./index.html');
    var icon = '';
    //  如果不是 Windows,使用 png 格式的图像
    if (process.platform != 'win32') {
        icon  = '../../../images/open.png';
    } else {  //  如果是 Windows,使用 ico 格式的图像
        icon = '../../../images/folder.ico';
    }
    const template = [
        {
            label: '文件',
            submenu: [
                {
                    label: '打开',
                    icon:icon  //  设置“打开”菜单项的图标
                },
                {
                    label: '重做',
                    role:'redo'
                }
            ]
        }
    ];

    const menu = Menu.buildFromTemplate(template);
    Menu.setApplicationMenu(menu);

    win.on('closed', () => {
      console.log('closed');
      win = null;
    })

  }

app.on('ready', createWindow)

app.on('activate', () => {
    if (win === null) {
        createWindow();
    }
})

动态创建菜单

动态添加菜单项的基本原理就是创建若各个 MenuItem 对象,每一个 MenuItem 对象相当于一个菜单项,然后将 MenuItem 对象逐个添加到 Menu 对象中,Menu 对象相当于带子菜单的菜单项。

index.html

<!DOCTYPE html>
<html>
<head>
    <!--  指定页面编码格式  -->
    <meta charset="UTF-8">
    <!--  指定页头信息 -->
    <title>动态添加菜单</title>
    <script src="event.js"></script>
</head>
<body>
    <h1>动态添加菜单</h1>
    <button onclick="onClick_AllOriginMenu()">添加最初的菜单</button>
    菜单文本:<input id="menuitem"/>
    <p><input id="radio" name="radio" type="radio"/> 单选<br><input id="checkbox" name="radio" type="radio"/> 多选</p>
    <button onclick="onClick_AddMenuItem()">动态添加菜单项目</button>
</body>
</html>

event.js

const electron = require('electron');
const app = electron.app;
const remote = electron.remote;
const BrowserWindow = remote.BrowserWindow;
const Menu  = remote.Menu;
const MenuItem  = remote.MenuItem;
function saveClick() {
   // 单击“保存”按钮后弹出一个窗口
    var win = new BrowserWindow({width:300,height:200});
    win.loadURL('https://geekori.com');
}
// 
var customMenu = new Menu();
//  添加最初的应用菜单
function onClick_AllOriginMenu() {

    const menu = new Menu();
    var icon = '';
    if (process.platform != 'win32') {
        icon  = '../../../images/open.png';
    } else {
        icon = '../../../images/folder.ico';
    }
   //  创建菜单项对应的 MenuItem 对象
    var menuitemOpen =    new MenuItem({label:'打开',icon:icon})
    var menuitemSave = new MenuItem({label:'保存',click:saveClick})
   // 创建带子菜单的菜单项
    var menuitemFile = new MenuItem({label:'文件',submenu:[menuitemOpen,menuitemSave]});
   // 创建用于定制的菜单项目
    menuitemCustom =  new MenuItem({label:'定制菜单',submenu:customMenu});

    menu.append(menuitemFile);
    menu.append(menuitemCustom);

    Menu.setApplicationMenu(menu);

}
//  动态添加菜单项
function onClick_AddMenuItem() {
    var type = 'normal';
    if(radio.checked)  {
        type = 'radio';      // 设为单选风格的菜单项
    }
    if(checkbox.checked)  {
        type  =  'checkbox';  //  设为多选风格的菜单项
    }
   //  动态添加菜单项
    customMenu.append(new MenuItem({label:menuitem.value,type:type}))
    menuitem.value = '';
    radio.checked = false;
    checkbox.checked=false;
   //  必须更新菜单,修改才能生效
    Menu.setApplicationMenu(Menu.getApplicationMenu());
}

上下文菜单

创建上下文菜单的方式与创建应用菜单的方式类似,只是不使用 Menu.setApplicationMenu 方法将菜单作为应用菜单显示,而是使用 menu.popup 方法在鼠标单击的位置弹出菜单

index.html

<!DOCTYPE html>
<html>
<head>
    <!--  指定页面编码格式  -->
    <meta charset="UTF-8">
    <!--  指定页头信息 -->
    <title>上下文菜单</title>
    <script src="event.js"></script>
</head>
<body onload="onload()">
    <h1>上下文菜单</h1>
    <div id = "panel" style="background-color: brown; width: 300px;height:200px"></div>
</body>
</html>

event.js

const electron = require('electron');
const app = electron.app;
const remote = electron.remote;
const BrowserWindow = remote.BrowserWindow;
const Menu  = remote.Menu;
const MenuItem  = remote.MenuItem;
const dialog = remote.dialog;
function  onload() {
    const menu = new Menu();
    var icon = '';
    if (process.platform != 'win32') {
        icon  = '../../../images/open.png';
    } else {
        icon = '../../../images/folder.ico';
    }
    const win = remote.getCurrentWindow();
    //  添加上下文菜单项,单击菜单项,会弹出打开对话框,并将选择的文件路径设置为窗口标题
    var menuitemOpen = new MenuItem({label:'打开',icon:icon,click:()=>{
        var paths =  dialog.showOpenDialog({properties: ['openFile']});
        if(paths  != undefined)
            win.setTitle(paths[0]);
    }});
    var menuitemSave = new MenuItem({label:'保存',click:saveClick})

    var menuitemFile = new MenuItem({label:'文件',submenu:[menuitemOpen,menuitemSave]});

    var menuitemInsertImage =  new MenuItem({label:'插入图像'});
    var menuitemRemoveImage =  new MenuItem({label:'删除图像'});

    menu.append(menuitemFile);
    menu.append(menuitemInsertImage);
    menu.append(menuitemRemoveImage);
   //  添加上下文菜单响应事件,只有单击鼠标右键,才会触发该事件
    panel.addEventListener('contextmenu',function(event) {
        //  阻止事件的默认行为,例如,submit 按钮将不会向 form 提交
       event.preventDefault();
       x = event.x;
       y = event.y;
       //  弹出上下文菜单
       menu.popup({x:x,y:y});
       return false;
    });
}

创建托盘应用

托盘应用需要设置托盘的图标,以及左击或右击图标时显示的上下文菜单等。

一个托盘图标由一个 Tray 对象表示,因此为应用程序添加托盘图标,首先要先创建一个 Tray 对象。注意,Tray 对象不需要像菜单一样通过特定的方法添加到托盘上,只要创建一个 Tray 对象就会自动将图标放到托盘上,如果在一个应用程序中创建多个 Tray 对象,那么就会在托盘中添加多个图标。

const {app, Menu, Tray,BrowserWindow} = require('electron')
let tray;
let contextMenu;
function createWindow () {
    win = new BrowserWindow({file: 'index.html'});
    win.loadFile('./index.html');
    //  创建 Tray 对象,并指定托盘图标
    tray = new Tray('../../../../images/open.png');
    //  创建用于托盘图标的上下文菜单
    contextMenu = Menu.buildFromTemplate([
        {label: '复制', role:'copy'},
        {label: '粘贴', role:'paste'},
        {label: '剪切', role:'cut'}
    ])
    //  设置托盘图标的提示文本
    tray.setToolTip('这是第一个托盘应用')
    //  将托盘图标与上下文菜单关联
    tray.setContextMenu(contextMenu)
    win.on('closed', () => {
        win = null;
    })
}

app.on('ready', createWindow)
app.on('activate', () => {
    if (win === null) {
        createWindow();
    }
})

系统并不会压缩托盘图标的尺寸,因此在设置托盘图标时,应该选择适当尺寸的图像文件,通常是16 × 16 大小。

为托盘图标设置上下文菜单:

contextMenu = Menu.buildFromTemplate([
    {label: '复制', role:'copy'},
    {label: '粘贴', role:'paste'},
    {label: '剪切', role:'cut'},
    {label: '关闭', role:'close',click:()=>{win.close()}}
])

Mac OS X 默认是单击鼠标左键弹出上下文菜单,在 Windows 是单击鼠标右键弹出上下文菜单,不过这个默认行为可以通过托盘事件修改。

Tray 有一个 right-click 事件,该事件在鼠标右键单击托盘图标时触发,可以在该事件中调用 popUpContextMenu 方法弹出上下文菜单,代码如下:

tray.on('right-click', (event) =>{
    tray.popUpContextMenu(contextMenu);
});

下面的代码演示了 Tray 中主要事件的使用方法:

const remote= require('electron').remote;
const Menu =  remote.Menu;
const Tray = remote.Tray;
let tray;
let contextMenu
//  添加托盘图标
function onClick_AddTray()  {
    if(tray != undefined) {
        return
    }
    tray = new Tray('../../../../images/open.png');
    var win = remote.getCurrentWindow();
    contextMenu = Menu.buildFromTemplate([
        {label: '复制', role:'copy'},
        {label: '粘贴', role:'paste'},
        {label: '剪切', role:'cut'},
        {label: '关闭', role:'close',click:()=>{win.close()}}

    ])
   /*
    为托盘图标添加鼠标右键单击事件,在该事件中,如果按住 shift 键,再单击鼠标右键,会弹出一个窗口,否则会弹出上下文菜单。

    如果为托盘图标绑定了上下文菜单,在 Windows 下不会响应该事件,这是因为 Windows 下是单击鼠标右键显示上下文菜单的,正好和这个 right-click 事件冲突。

    event 参数包括下面的属性,表明当前是否按了对应的键。
    1. altKey:Alt 键
    2. shiftKey:Shift 键
    3. ctrlKey:Ctrl 键
    4. metaKey:Meta 键,在 Mac OS X 下是 Command 键,在 Windows 下是窗口键(开始菜单键)
   */
    tray.on('right-click', (event) =>{
        textarea.value += '\r\n' + 'right-click';
        if(event.shiftKey) {
            window.open('https://www.baidu.com','right-click','width=300,height=200')
        } else  {
            //  单击鼠标右键弹出上下文菜单
            tray.popUpContextMenu(contextMenu);
        }
    });
   /*
    为托盘图标添加鼠标单击事件,在该事件中,如果按住 shift 键,再单击鼠标左键或右键,会弹出一个窗口,否则会弹出上下文菜单。
    如果将上下文菜单与托盘图标绑定,在 Mac OS X 下,单击鼠标左键不会触发该事件,这是由于 Mac OS X 下是单击鼠标左键弹出上下文菜单,与这个事件冲突
   */
    tray.on('click', (event) =>{
        textarea.value += '\r\n' + 'click';
        if(event.shiftKey) {
            window.open('https://www.baidu.com','click','width=300,height=200')
        } else  {
            //  单击鼠标右键弹出上下文菜单
            tray.popUpContextMenu(contextMenu);
        }
    });
   /*
    当任何东西拖动到托盘图标上时触发,读者可以从 word 中拖动文本到托盘图标上观察效果
    Only Mac OS X
    */
    tray.on('drop',()=>{
        textarea.value += '\r\n' + 'drop';

    });
   /*
    当文件拖动到托盘图标上时会触发,files 参数是 String 类型数组,表示拖动到托盘图标上的文件名列表
    Only Mac OS X
    */
    tray.on('drop-files',(event,files)=>{
        textarea.value += '\r\n' + 'drop-files';
        //  输出所有拖动到托盘图标上的文件路径
        for(var i = 0; i < files.length;i++) {
            textarea.value += files[i] + '\r\n';
        }
    });
   /*
    当文本拖动到托盘图标上时会触发,text 参数是 String 类型,表示拖动到托盘图标上的文本
    Only Mac OS X
    */
    tray.on('drop-files',(event,files)=>{
        textarea.value += '\r\n' + 'drop-files';
        for(var i = 0; i < files.length;i++) {
            textarea.value += files[i] + '\r\n';
        }
    });    
    tray.setToolTip('托盘事件')
    tray.setContextMenu(contextMenu)
}

Tray 类提供了多个方法用来控制托盘图标,如设置托盘图标、设置托盘文本、移除托盘图标等:

//  设置托盘图像
function  onClick_SetImage() {
    if(tray != undefined) {
        tray.setImage('../../../../images/note1.png')
    }
}
//  设置托盘标题(仅适用于Mac OS X)
function onClick_SetTitle() {
    if(tray != undefined) {
        tray.setTitle('hello world')
    }
}
//  设置托盘按下显示的图标(仅适用于Mac OS X)
function onClick_SetPressedImage() {
    if(tray != undefined) {
        tray.setPressedImage('../../../../images/open.png')
    }
}
//  设置托盘提示文本
function onClick_SetTooltip() {
    if(tray != undefined) {
        tray.setToolTip('This is a tray')
    }
}
//  移除托盘
function onClick_RemoveTray()  {
    if(tray != undefined) {
        tray.destroy();
        tray = undefined;   //  应该将tray设为undefined,否则无法再创建托盘对象
    }
}

显示气泡信息

仅Windows

const remote= require('electron').remote;
const Menu =  remote.Menu;
const Tray = remote.Tray;
var tray;
var contextMenu;

function onClick_AddTray()  {
    if(tray != undefined  ) {
        return
    }
    tray = new Tray('../../../../images/open.png');
    var win = remote.getCurrentWindow();
    contextMenu = Menu.buildFromTemplate([
        {label: '复制', role:'copy'},
        {label: '粘贴', role:'paste'},
        {label: '剪切', role:'cut'},
        {label: '关闭', role:'close',click:()=>{win.close()}}
    ])
   //  添加气泡消息显示事件
    tray.on('balloon-show',()=>{
        log.value += 'balloon-show\r\n';
    });
    //  添加气泡消息单击事件
    tray.on('balloon-click',()=>{
        log.value += 'balloon-click\r\n';
    });
    //  添加气泡消息关闭事件
    tray.on('balloon-closed',()=>{
        log.value += 'balloon-closed\r\n';
    });
    tray.setToolTip('托盘事件')
    tray.setContextMenu(contextMenu)
}
function onClick_DisplayBalloon() {
    if(tray != undefined) {
        //  显示气泡消息
        tray.displayBalloon({title:'有消息了',icon:'../../../../images/note.png',content:'软件有更新了,\r\n赶快下载啊'})
    }
}

气泡消息包含如下 3 个事件:

  • balloon-show,当气泡消息显示时触发;
  • balloon-click,当单击气泡消息时触发;
  • balloon-closed,当气泡消息关闭时触发。

其中 balloon-click 和 balloon-closed 是互斥的,也就是说,单击气泡消息后,气泡消息会立刻关闭,在这种情况下,并不会触发 balloon-closed 事件。因此 balloon-closed 事件只有当气泡消息自己关闭后才会触发,气泡消息在显示几秒后会自动关闭。

一阶低通滤波算法应用

这段时间我一直在调英雄车的云台PID,之前我放弃了陀螺仪的角速度数据,选择用电机的编码盘反馈的位置计算角速度,结果效果很棒!详情可以点击[这里]?id=13)。

但是这个方法还有一个遗留问题,那就是计算出来的角速度有波动,曲线画出来呈锯齿状,我已经使用了递推平均滤波算法,而且队列长度已经设置到了24位,再往上提升效果也不是很显著,反而浪费了宝贵的RAM资源。

产生锯齿波的根本原因是编码器的精度很高,一圈又8192个刻度,稍微的影响都会使反馈值改变,这是硬件层面的现状,是无法避免的,我需要从这些锯齿波中得到角速度变化的真正趋势。

因为是锯齿波,所以我选择了低通滤波算法。这里我只使用了一阶低通滤波算法。

首先介绍一下啥是一阶低通滤波算法:

低通滤波原本是硬件层面RC滤波电路,后面有了软件模拟的低通滤波。

一阶低通滤波又叫一阶惯性滤波,算法公式为:

式中:为滤波系数,取值范围为0~1;X(n)为本次采样值;Y(n-1)为上次滤波输出值;Y(n)为本次滤波输出值。

显然,一阶低通滤波算法就是将本次的采样值和上次的输出值进行加权得到有效的滤波值。

一阶滤波算法的不足就是平稳度和灵敏度不可兼得,这也是大多数滤波算法的通病。

对于一阶滤波算法,滤波系数越小,滤波结果越平稳,但是灵敏度越低;滤波系数越大,灵敏度越高,但是滤波结果越不稳定`。

我测试了为0.2,0.25和0.3的情况,结果如下:

= 0.3
image

= 0.25
image

= 0.2
image

从上面的对比可以确实是越小结果越平稳。但是从下图也可以看出灵敏度的下降(曲线已经有了相位差)

image

最后我选择了 = 0.2,平稳度和灵敏度都比较好。

附上我的实现代码(我是在电机的数据接收中断里面进行处理的

/**
* @brief CAN通信电机的反馈数据具体解析函数
* @param 电机数据结构体
* @retval None
*/
void CanDataEncoderProcess(struct CAN_Motor *motor)
{
  int temp_sum = 0;
  motor->last_fdbPosition = motor->fdbPosition;
	motor->fdbPosition = CanReceiveData[0]<<8|CanReceiveData[1];
	motor->fdbSpeed = CanReceiveData[2]<<8|CanReceiveData[3];
  motor->last_real_position = motor->real_position;
  
  /* 电机位置数据过零处理,避免出现位置突变的情况 */
  if(motor->fdbPosition - motor->last_fdbPosition > 4096)
  {
    motor->round --;
  }else if(motor -> fdbPosition - motor->last_fdbPosition < -4096)
  {
    motor->round ++;
  }
  
	motor->real_position = motor->fdbPosition + motor->round * 8192;
  
  motor->diff = motor->real_position - motor->last_real_position;
  motor->position_buf[motor->index] = motor->diff;
  motor->index ++;
  if(motor->index == 24)
  {
    motor->index = 0;
  }
  
  for(int i=0; i<24;i++)
  {
    temp_sum += motor->position_buf[i];
  }
  motor->velocity1 = temp_sum*1.83;        //默认单位是8192,转换为360,毫秒转化为秒,加上这里累加了最近的24个数据,所以算式为temp_sum*360*1000/8192/24 = 1.83
  motor->last_velocity = motor->velocity;
  motor->velocity = motor->alpha * motor->velocity1 + (1 - motor->alpha) * motor->last_velocity;
}

解决MDK版本过高不兼容jlink驱动的问题

重装了电脑之后突然想尝试一下最新版的keil MDK,之前一直用的是MDK 520版本,这次直接升级到了MDK 526版本。

新版本其他都还挺好,但就是调试的时候会跳出这样一个提示框:
image

出现提示框不久调试就会自动终止,调试起来非常费劲。

百度了一下发现是因为高版本的MDK自带的jlink驱动与jlink调试器里面的驱动版本不兼容,需要手动更换驱动。

只需要把驱动下载下来,解压,用里面的文件替换掉keil安装目录下的ARM\Segger\文件夹里的同名文件,就行了。

下载地址

这里下载。

下载Google Play应用

偶尔会遇到要上Google Play下载APP的情况,但是Google Play上电脑还不能直接下载,我搞到Google账号之后,居然需要绑定移动设备,太麻烦了。

之前我一直用的是apkpure,但后来它也挂了,没办法。

后来我再Chrome应用商城中发现了一个叫APK Download的扩展应用,直接复制应用下载界面的url就行了。
image

此外还可以使用这个链接在线下载,不用安装扩展应用。

关于电机数据为无符号整形的处理

前段时间我使用电机的时候,在电机的数据结构体中都是使用int类型

struct CAN_Motor
{
    int fdbPosition;        //电机的编码器反馈值
    int last_fdbPosition;   //电机上次的编码器反馈值
    int bias_position;      //机器人初始状态电机位置环设定值
    int fdbSpeed;           //电机反馈的转速/rpm
    int round;              //电机转过的圈数
    int real_position;      //过零处理后的电机转子位置
};

电机的通信协议如下:

数据域 内容
DATA[0] 转子机械角度高8位
DATA[1] 转子机械角度低8位
DATA[2] 转子转速高8位
DATA[3] 转子转速低8位
DATA[4] 实际转矩电流高8位
DATA[5] 实际转矩电流低8位
DATA[6] 电机温度
DATA[7] Null

我的接收处理是这样的:

motor->position = CanReceiveData[0]<<8|CanReceiveData[1];
motor->speed = CanReceiveData[2]<<8|CanReceiveData[3];

但是这样有个问题,就是当速度为负值的时候,比如现在电机转速是-1,但是我接收到的数据为65535,也就是原本应该是有符号的整形变成了无符号整形,所以我必须加下面的语句手动处理:

/* 将电机速度反馈值由无符号整型转变为有符号整型 */
if(motor->fdbSpeed > 32768)
{
	motor->fdbSpeed -= 65536;
}

这样一来就显得非常麻烦,还好现在我找到了解决办法,就是把结构体里面的int类型改成short类型,就没有这个问题了。

struct CAN_Motor
{
  short position;
  short speed;
};

这是因为short类型默认是由符号的,取值范围为-32768~32767,哪怕输入的确实是65535,它也会在数据类型转换的时候自动转变为-1,省去了我们手动处理的步骤。

机器学习-什么是机器学习

吵了近一年的机器学习入门计划终于下地了,从今天开始学习机器学习,之后入门深度学习,进入人工智能领域。

机器学习部分学习参照的是目前最为火爆的吴恩达的机器学习教程,浅显易懂。机器学习会涉及到很多数学知识,但吴恩达的教程是众多教程中最为对新手友好的,他尽力地不过多地涉及繁杂的数学知识,专注于讲解机器学习的原理,且循循善诱,步步深入,一看就停不下来。

该系列总结同时参考了该门课程的翻译者黄海广博士的个人总结

好,机器学习就此开启吧!


机器学习是目前信息技术中最激动人心的方向之一。我们每天都在不知不觉中使用机器学习的算法。

每次,你打开谷歌、必应搜索到你需要的内容,正是因为他们有良好的学习算法。谷歌和微软实现了学习算法来排行网页。

每次,你用Facebook或苹果的图片分类程序他能认出你朋友的照片,这也是机器学习。

每次您阅读您的电子邮件垃圾邮件筛选器,可以帮你过滤大量的垃圾邮件这也是一种学习算法。

对我来说,我感到激动的原因之一是有一天做出一个和人类一样聪明的机器。实现这个想法任重而道远,许多AI研究者认为,实现这个目标最好的方法是通过让机器试着模仿人的大脑学习。

为什么机器学习如此受欢迎呢?原因是,机器学习不只是用于人工智能领域。

我们创造智能的机器,有很多基础的知识。比如,我们可以让机器找到AB之间的最短路径,但我们仍然不知道怎么让机器做更有趣的事情,如web搜索、照片标记、反垃圾邮件。我们发现,唯一方法是让机器自己学习怎么来解决问题。所以,机器学习已经成为计算机的一个能力。

这里有一些机器学习的案例。

比如说,数据库挖掘。机器学习被用于数据挖掘的原因之一是网络和自动化技术的增长,这意味着,我们有史上最大的数据集比如说,大量的硅谷公司正在收集web上的单击数据,也称为点击流数据,并尝试使用机器学习算法来分析数据,更好的了解用户,并为用户提供更好的服务。这在硅谷有巨大的市场。

再比如,医疗记录。随着自动化的出现,我们现在有了电子医疗记录。如果我们可以把医疗记录变成医学知识,我们就可以更好地理解疾病。

再如,计算生物学。还是因为自动化技术,生物学家们收集的大量基因数据序列、DNA序列和等等,机器运行算法让我们更好地了解人类基因组,大家都知道这对人类意味着什么。

再比如,工程方面,在工程的所有领域,我们有越来越大、越来越大的数据集,我们试图使用学习算法,来理解这些数据。

手写识别:现在我们能够非常便宜地把信寄到这个美国甚至全世界的原因之一就是当你写一个像这样的信封,一种学习算法已经学会如何读你信封,它可以自动选择路径,所以我们只需要花几个美分把这封信寄到数千英里外。

事实上,如果你看过自然语言处理或计算机视觉,这些语言理解或图像理解都是属于AI领域。大部分的自然语言处理和大部分的计算机视觉,都应用了机器学习。

学习算法还广泛用于自定制程序。每次你去亚马逊或NetflixiTunes Genius,它都会给出其他电影或产品或音乐的建议,这是一种学习算法。仔细想一想,他们有百万的用户;但他们没有办法为百万用户,编写百万个不同程序。软件能给这些自定制的建议的唯一方法是通过学习你的行为,来为你定制服务。

最后学习算法被用来理解人类的学习和了解大脑。

RM2018的奋斗

整理电脑文件的时候发现了去年的奋斗史,截个图纪念一下
QQ截图20190309160719

RM2019继续加油!
🥇

多摩川编码器使用总结

在驱动步进电机成功之后(可看这篇文章了解详情),剩下的就是读取编码器的值了。

编码器使用的是多摩川(Tamagawa)的39位高精度编码器(23位单圈精度+16位多圈精度),支持RS485通信,波特率为2.5Mbps,数据长度为八位。型号为TS5700N8401,属于SA48系列编码器。

使用起来很简单,只要数据手册正确以及模块能满足功能。。。这里就记录一下我这一周所踩的坑。

RS485通信

RS485通信是从RS232中衍生出来的,解决了RS232不能联网通信的痛点。

RS485与串口通信非常相似,也是两根线,但它可以像CAN通信一样,一条总线上搭载多个设备,实现联网通信。两根线分别为A线和B线,这里就说说我踩的第一个坑:

编码器的说明书里把两个数据线写作:
image

这我怎么知道哪根是A线哪根是B线啊,百度也百度不到,拿头来接吗。。

没办法,只能试错法,结果一次“成功”,我成功收到了数据。结果后来仔细一看这数据是我自己的发出去的,当时就自闭了,为啥我会收到我自己发的数据?

后来的某个晚上我突然开窍,原来是接反了,A、B两线反接就跟TX和RX短接一样,RX的信号立马被TX捕获,造成了接收到编码器数据的假象。

USART低位优先

搞懂了这一点后我把线反接回来,但这下一点反馈信息都没有了,编码器就像死了一样。没办法,看通信协议哪里写错了吧。

(这里又要吐槽一下,这编码器的数据手册是真的难找,淘宝店家给的和公司网站找到的都只有编码器的性能参数和接线方法,死活没有通信协议,后来我从CSDN上才找到一个型号很相近的TS5700N8501编码器的数据手册,这下才开工)

下载地址: https://download.csdn.net/download/ppl002/10787122

读了数据手册才知道编码器可以读取绝对值角度数据,也可以读写数据到内置的EEPROM中,当然我这里只需要读取绝对值角度数据就行了。

通信协议如下:
image

image

image

image

我先读取编码器的单圈数值吧,所以我要请求DATA_ID_0

起始位&停止位

手册里写的是CF由10bits,但众所周知USART一次只能发送8bits的数据,而这里显然是不能分两次发送的。

后来我才领悟到原来这里的起始位和停止位并不需要手动发送,因为USART的通信协议中就自带了起始位和停止位。所以这里我只需要发送0100 0000就行了,也就是0x40

但是发送过去毫无反应,再次自闭。

后来我又突然想起,USART是先发送低位数据再发送高位数据的,所以这里发送的不应该是0x40,而应该是0000 0010,也就是0x02!!

MAX485

修正之后,还是没有接收到任何编码器的反馈数据,不由得反思起来。

终于,我把目光看向了手上的485芯片。因为我是直接用的参加RoboMaster比赛使用的STM32开发板,上面并没有485收发芯片,所以我单独买了一个485芯片。我买的芯片型号是MAX13487EESA,一查数据手册发现这货最高只支持到500Kbps,远远不能满足编码器2.5Mbps的需求。没办法,换吧。

然后我换了MAX485芯片,它所支持的最高频率刚好就是2.5Mbps。

大功告成

等了两天,MAX485终于到货了,把线接上,把写好的代码烧进去,进入调试,哈哈哈!终于收到编码器的反馈数据了。

但是,嗯?感觉还是有点问题,数据不太对。

然后我就开始漫无目的地百度搜索多摩川编码器反馈值乱码,还真给我搜到了。原来是编码器刚上电的时候只有5位精度,需要手动旋转11.25度一样才能达到正常精度,怎么会有这种操作。

原文链接

于是我先上电,扭一下编码器,然后进入调试,一看,哈!数据正常了!而且单圈精度并不是像说明书写的那样是17位,而是23位!!!

尾声

至此,多摩川编码器基本上算是被我征服了。犯了很多低级的错误,踩了很多坑,记录下来引以为戒。

最后附上我的部分代码:

  • while循环
while (1)
  {
	nrz_read(DATA_ID_3);
	HAL_Delay(1);
	rx_parse(USART6_RX_Buff);
	HAL_Delay(10);
  }
  • nrz_read函数
void nrz_read(uint8_t data_id)
{
	ti.data_id = data_id;
	nrz_rx_cnt = 0;
	tx_prepare(&tx, &tx_size, &rx_size);
	nrz_tx(tx_size, &tx);
}
  • tx_prepare函数
void tx_prepare(uint8_t *tx, uint8_t *tx_size, uint8_t *rx_size)
{
    switch(ti.data_id)
    {
        case DATA_ID_0:
            *tx = 0x02;
            *tx_size = 1;
            *rx_size = 6;
            break;

        case DATA_ID_1:
            *tx = 0x8A;
            *tx_size = 1;
            *rx_size = 6;
            break;

        case DATA_ID_2:
            *tx = 0x92;
            *tx_size = 1;
            *rx_size = 4;
            break;

        case DATA_ID_3:
            *tx = 0x1A;
            *tx_size = 1;
            *rx_size = 11;
            break;

        case DATA_ID_7:
            *tx = 0xBA;
            *tx_size = 1;
            *rx_size = 6;
            break;

        case DATA_ID_8:
            *tx = 0xC2;
            *tx_size = 1;
            *rx_size = 6;
            break;

        case DATA_ID_C:
            *tx = 0x62;
            *tx_size = 1;
            *rx_size = 6;
            break;

        case DATA_ID_6:
            *tx++ = 0x32;
            *tx++ = ti.tx.adf;
            *tx++ = ti.tx.edf;
            *tx_size = 4;
            *rx_size = 4;
            *tx = crc(tx - 3, *tx_size - 1);
            break;

        case DATA_ID_D:
            *tx++ = 0xEA;
            *tx++ = ti.tx.adf;
            *tx_size = 3;
            *rx_size = 4;
            *tx = crc(tx - 2, *tx_size - 1);
            break;

        default:
            break;
    }
}
  • rx_parse函数
void rx_parse(uint8_t *p)
{
    switch(ti.data_id)
    {
        case DATA_ID_0:
        {
            ti.rx.cf = p[0];
            ti.rx.sf = p[1];
            ti.rx.abs = p[2] | (p[3] << 8) | (p[4] << 16);
            ti.rx.crc = p[5];
            break;
        }
        case DATA_ID_7:
            break;
        case DATA_ID_8:
            break;
        case DATA_ID_C:
            ti.rx.cf = p[0];
            ti.rx.sf = p[1];
            ti.rx.abs = p[2] | (p[3] << 8) | (p[4] << 16);
            ti.rx.crc = p[5];
            break;

        case DATA_ID_1:
            ti.rx.cf = p[0];
            ti.rx.sf = p[1];
            ti.rx.abm = p[2] | (p[3] << 8) | (p[4] << 16);
            ti.rx.crc = p[5];
            break;

        case DATA_ID_2:
            ti.rx.cf = p[0];
            ti.rx.sf = p[1];
            ti.rx.enid = p[2];
            ti.rx.crc = p[3];
            break;

        case DATA_ID_3:
            ti.rx.cf = p[0];
            ti.rx.sf = p[1];
            ti.rx.abs = p[2] | (p[3] << 8) | (p[4] << 16);
            ti.rx.enid = p[5];
            ti.rx.abm = p[6] | (p[7] << 8) | (p[8] << 16);
            ti.rx.almc = p[9];
            ti.rx.crc = p[10];
            break;

        case DATA_ID_6:
            break;
        case DATA_ID_D:
            ti.rx.cf = p[0];
            ti.rx.adf = p[1];
            ti.rx.edf = p[2];
            ti.rx.crc = p[3];
            break;

        default:break;
    }
}

网站改造总结

前几天春节期间花了点时间把个人网站改造了一下,在这里做个总结Mark一下。

改造的念头来源于一次无意间翻到的一个在issue页面建立的博客(相当于只有后台的博客),我就有了要将网站与issue挂钩的想法。

之前我的网站是基于Editor.md的,以在本地写好相应的.md文件后push到GitHub上的方式来发表文章,也不是太麻烦,唯有一个遗憾,就是没有评论功能。去年的时候我接触过Gitment,但当时还不太了解GitHub,更别说issue了,而且Gitment需要手动为每个文章初始化一次,显得比较麻烦。现在作者网站的安全证书过期了,导致Gitment访问不是很稳定。

于是我决定自己来试着把GitHub的issues和comments都集成到我的网站里。

首先是注册一个GitHub的OAuth App,点这里可以注册。注册后可以拿到对应的client_idclient_secret

最开始我一直想通过纯JavaScript实现此功能,但总是遇到各种各样的问题,最后遇到了一个实在无解的问题,详情可以点这里,然后我发邮件询问GitHub,结果直接来了个官方劝退:

Exchanging a code for a token is not possible from a browser via JavaScript -- it's not something that our OAuth flow supports and you'll run into several problems stopping you if you try doing that. Instead, you'll need to have a server-side component which makes that request for exchanging the code for a token.

看来只能再来个服务端的程序了,怪不得Gitment老是要访问作者的那个网址。

我使用了最熟悉的PHP,代码很简单,如下:

<?php
$code = $_GET['code'];
$redirect_url = $_GET['state'];

$client_id = 'my client_id';
$client_secret = 'my client_secret';

$url = "https://github.com/login/oauth/access_token?client_id=".$client_id."&client_secret=".$client_secret."&code=".$code;
 
$html= file_get_contents($url);

$data = explode("&", $html);
$token = explode("=", $data[0]);
?>
<script>
window.location.href = '<?php echo $redirect_url; ?>&access_token='+'<?php echo $token[1]; ?>';
</script>

在获取到access_token后,我拿着这个token进一步获取到了登录用户的用户名和头像。

我直接使用了Gitment的css样式,具体样式可以直接拉到文章底部查看。

这里还有一个坑。其实也不算是坑,只是我不太了解OAuth,相关注意事项全都写在文档里面的。

我开始申请到access_token后,尝试添加评论,但是老是提示我Must have admin rights to Repository.,header提示403 Forbidden,这是没有访问repository的意思,后来我才知道,原来申请code的时候要在get参数里面加上'scpoe=public_repo`,才能授予用户修改的权限。

最后我又完善了一些分页功能,首页以列表形式展示issue的时候去除HTML标签。

然后就可以体验全新的网站啦。

关于这些功能的实现具体可以查看我的源代码issue-comment.js

HAL库实现us级延时

HAL库自带的延时函数HAL_Delay(uint32_t time_ms)基于Systick定时器,只能实现毫秒级延时,但在很多外设通信中,通信时间是以微秒为单位的,自带的延时函数远远无法满足要求。

STM32有好几种定时器,既有高级的,也有低级的,何不使用低级定时器实现手动实现微秒延时呢?

Systick定时器是HAL的默认时钟源,最好还是不要乱用,我这里使用TIM7,在STM32CubeMX中预分配为1MHz,对应1us,通过操作定时器的CNT寄存器实现延时,代码很简单:

void Delay_us(uint16_t time)
{
	uint16_t differ = 0xffff-time-5;
	__HAL_TIM_SET_COUNTER(&htim7, differ);
	HAL_TIM_Base_Start(&htim7);
	while(__HAL_TIM_GET_COUNTER(&htim7) < 0xffff-6){}	
	HAL_TIM_Base_Stop(&htim7);
}

实测真的非常准确!!


参考:STM32 HAL库实现微秒级别延时

Phaser 飞机大战学习(二)

图片加载

背景图片科技使用game.add.image(0,0,'backgroud'),也可以使用game.add.sprite(0,0,'background')

帧动画加载

帧动画加载就必须用game.add.sprite函数
帧动画在加载资源的时候就已经处理好了,使用的是game.load.spritesheet函数
但是现在加载出来是没有动画的,让他动起来的步骤如下:

var myplane = game.add.sprite(100, 100, 'myplane');
myplane.animations.add('fly');
myplane.animations.play('fly');

但是逐帧动画默认不循环,所以应该这样写:

myplane.animations.play('fly', 12,  true);
//第一个参数是动画的名称,第二个参数是帧动画播放的快慢,第三个参数指定是否循环播放

添加一个开始按钮

使用了一个button函数,函数定义如下

new Button(game, x, y, callback, callbackContext, overFrame, outFrame, downFrame, upFrame);//Phaser.Button
//回调函数,回调函数上下文,鼠标放上去的帧,移开的帧,按下的帧,抬起的帧

添加开始按钮的代码如下:

game.MyStates.start = {
	create: function() {
		//背景,飞机动画的加载
		game.add.button(70, 200, 'startbutton', this.onStartClick, this, 1, 1, 0);
	},
	
	onStartClick: function() {
		console.log('click');
	}
}

下面添加一个游戏中的场景

game.MyStates.play= {
	create: function() {
		game.add.tileSprite(0, 0, game.width, game.height, 'backgound');		//里面的资源会平铺
	}
}

上面的背景需要滚动,所以选用了tileSprite。但是上面的代码实现的是平铺的效果,如果要实现滚动效果,需要这样写:

var bg = game.add.tileSprite(0, 0, game.width, game.height, 'backgound');
bg.autoScoll(0, 20);	//水平方向不滚动,竖直方向滚动速度20

渐变动画

初始状态给飞机添加一个渐变动画

game.MyStates.play= {
	create: function() {
		game.add.tileSprite(0, 0, game.width, game.height, 'backgound');		//里面的资源会平铺
		bg.autoScoll(0, 20);	//水平方向不滚动,竖直方向滚动速度20
		var myplane = game.add.sprite(100, 100, 'myplane');
		myplane.animations.add('fly');
		myplane.animations.play('fly');
		game.add.tween(myplane).to({y: game.height - 40}, 1000, null, true);		//函数用法参考Phaser.Tween。动画效果、动画时间,简便类型(null表示默认 Phaser.Easing),自动播放
	}
}

可以给渐变动画加上一个回调函数,上述代码改写如下:

create: function() {
	//省略代码
	var tween = game.add.tween(myplane).to({y: game.height - 40}, 1000, null, true);
	tween.onComplete.add(this.onStart, this);
}

onStart: function() {
	console.log('onStart');
}

添加分数

飞机渐变动画结束之后在左上角添加分数。

onStart: function() {
	//console.log('onStart');
	var style = {font: "16px Arial", fill: "#ff0000"};
	var text = game.add.text(0, 0, "Scroe: 0", style);
}

飞机拖拽

前面加载飞机场景的时候使用的是var myplane = game.add.sprite(100, 100, 'myplane');这样是不能实现拖拽的,要改成this.myplane = game.add.sprite(100, 100, 'myplane');之后和myplane相关的语句都要加上this。然后使用phaser自带的一些函数来实现飞机的拖拽效果。

onStart: function() {
	this.myplane.inputEnabled = true;	//允许输入
	this.myplane.input.enableDrag(false);	//允许拖动
}

实现子弹

一个方法就是使用定时器实现,还可以使用粒子发射器实现。这里直接在update里面实现子弹。

onStart: function() {
	//省略
	this.myplane.myStartFire = true;	//先定义一个开火的标记。
},
update: function() {
	if(this.myplane.myStartFire) {
		var myBullet = game.add.sprite(this.myplane.x, this.myplane.y, 'mybullet');	//后面要进行物理碰撞,所以使用sprite
	}
}

接下来给整个游戏加上物理引擎

game.MyStates.play = {
	create: function() {
		game.physics.startSystem(Phaser.Phsics.ARCADE);
	},
	update: function() {
		game.physics.enable(myBullet, Phaser.Physics.ARCADE);
		myBullet.body.velocity.y = -200;
	}
}

上面的子弹发射的特别快

update:
	var now = new Date().getTime();
	if(this.myplane.myStartFire && now - this.lastBulletTime > 500) {
		//发射子弹
		this.lastBulletTime = now;
	}
onStart: function() {
	this.lastBulletTime = 0;
}

上面给子弹加物理引擎的game.physics.enable(myBullet, Phaser.Physics.ARCADE);这一句可以换成game.physics.arcade.enable(this.myBullet)
下面让飞机不超出画面

game.physics.arcade.enable(this.myplane);
this.myplane.body.collideWorldBounds= true;		//只有打开物理引擎之后才有body属性

添加敌机

this.enemy = game.add.sprite(100,10,'enemy1');
game.physics.arcade.enable(this.enemy);

给子弹创建group

onStart: function() {
	this.myBullets = game.add.group();
	this.myBullets.enableBody = true;	//给group开始物理引擎
}
update: function() {
	if(this.myplane.myStartFire && now - this.lastBulletTime > 500) {
		//发射子弹
		this.myBullets.add(myBullet);
	}
	game.physics.arcade.overlap(this.myBullets, this.enemy, this.collisionHandle, null, this);	//碰撞检测
	//game.physics.arcade.collide();可以实现碰撞效果,设置质量可以看见
}
collisionHandle: function() {
	//碰撞的回调函数
	console.log('collisionHandle');
}

击杀敌机

collisionHandle: function(enemy, bullet) {
	enemy.kill();
	bullet.kill();
}

生成漂亮的代码分享图

当我们要分享一段代码的时候,通常流程是使用截图工具,然后增加一些备注等等。最近发现一个小工具 - carbon。

carbon是一个在线工具,它负责生成更加漂亮不同风格的代码图片,绝对比截图工具更加搞逼格。

效果图:
carbon

地址:Carbon

Phaser 飞机大战学习(一)

从前年开始我就一直想进军H5游戏的领域,但是当时知识尚欠,连CANVAS这个标签都学不下去,后来我接触到了three.jspixi.js,一个是3D的,基于WebGL,一个是2D的,都很厉害,但最后都因为各种原因没有学下去。

前段时间我接触无意间接触到了phaser.js,这个游戏框架是专门用于2D游戏开发的,其实不只是开发游戏,动画也是可以做的。phaser.js框架好处在于它的小巧,而且是基于pixi.js的,入手很方便,但目前它的中文资料还不是很多,中文资料里面最赞的是phaser小站,我的phaser学习也是通过这个小站入门的,这里就记录一下我前段时间学习phaser的一些收获。

创建游戏场景

首先明确一点,phaser是基于canvas绘图的,使用起来也很简单,只需要在html里面建立一个div标签并指定id,然后引入phaser.min.js文件,最后创建游戏就行了,最简单的代码如下:

<!DOCTYPE html>
<html>
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
		<title>your title here</title>
		<script src="../phaser.min.js"></script>
	</head>
	<body>
		<div id="game" class="game"></div>
	</body>
</html>
var game = new Phaser.Game(240, 400, Phaser.CANVAS, 'game');

这时候在服务器上运行,就会发现网页中出现了一个240×400的黑块,这就是我们的游戏界面,查看DOM元素可以发现phaser在我定义的div标签中创建了一个canvas,然后在canvas里面实时渲染。

上面的代码中创建游戏场景只有一句话,也就是new Phaser.Game,游戏的每一个场景又可以分为preload,create,update几个阶段,每一个阶段都可以指定对应的函数操作,显然游戏中最重要的就是update这个阶段,我们一般在preload中加载我们游戏场景所需要的资源,在create中使用资源创建场景,然后在update中不断得更新场景,比如实现游戏逻辑,监听用户输入等等。

那么怎么为每个阶段指定函数呢?主要有两种,第一种如下:

var game = new Phaser.Game(240, 400, Phaser.CANVAS, 'game', {preload: preload, create: create, update: update});	//如果是手机游戏,一定要选用Phaser.CANVAS
function preload() {
  console.log("preload");
}

function create() {
  console.log('create');
}

function update() {
  console.log('update');
}

上面的代码是直接在创建的时候将每一个阶段的操作以对象的方式传给Phaser.Game,也可以换成下面的写法,更美观好看一点:

var state = {preload: preload, create: create, update: update};	//游戏场景

function preload() {
  console.log("preload");
}

function create() {
  console.log('create');
}

function update() {
  console.log('update');
}

game.state.add('state', state);

这样的好处是可以添加多个场景。

另一种方式是以函数的形式添加:

function state() {
  this.preload = function() {
    console.log("preload");
  }

  this.create = function() {
    console.log("create");
  }

  this.update = function() {
    console.log("update");
  }
}

game.state.add('state', new state());

当然两种方法,第一种方法是更好一点。

接下来我们可以对我们的代码做一些优化。假设我们的游戏有多个场景(事实上这是很有可能的),那么我们可以在game下面建一个场景的组合:

game.MyStates = {};

game.MyStates.state1 = {
  preload: function() {
    console.log("preload1");
  },
  create: function() {
    console.log("create1");
  },
  update: function() {
    console.log("update1");
  }
};

game.state.add('state1', game.MyStates.state1);

光添加这些场景还不行,我们还要启动场景,场景启动以后画面才会显示出来。场景启动的代码就一句话:

game.state.start('state2');

到目前为止我么的场景就建好了,现在就可以在浏览器中感受preloadcreateupdate之间的区别和特点了。

为游戏添加背景

现在我们的游戏还只是一个黑块,里面什么东西都没有,现在向其中添加一张图片作为游戏背景。

game.MyStates.state1 = {
  preload: function() {
    //console.log("preload1");
	game.load.image('background', 'assets/bg.jpg');
  },
  create: function() {
    //console.log("create1");
	var bg = game.add.tileSprite(0, 0, game.width, game.height, 'background');	//里面的资源会平铺
	bg.autoScoll(0, 20);	//水平方向不滚动,竖直方向滚动速度20
  },
  update: function() {
    console.log("update1");
  }
};

上述代码的功能其实已经显而易见了。这里只有一点要注意,那就是背景使用的是tileSprite,因为背景要实现不断地滚动,开启滚动就使用autoScoll函数。

游戏场景切换

游戏当然不只是一个场景啦,那么怎么实现场景切换呢?也很简单,只需要在其中一个场景里面启动另一个场景就行了。

game.MyStates.load = {
  preload: function() {
    //加载资源,加载完毕之后才会执行create方法
	game.load.image('background', 'assets/bg.jpg');
  },
  create: function() {
    game.state.start('state1');
  }
}

这里我把加载资源的操作单独放在了一个场景当中,等资源加载完毕之后再跳转到场景state1中。

这里有一个小知识点,就是场景切换的时候可以选择清空所有的场景中所有的精灵,也可以选择保留,但一般来说都是选择清空所有的精灵,这样子编程的时候逻辑也会更加清晰一点。

加载进度条

如果资源加载过多,那么游戏就会先黑屏很久然后才开始启动画面,这样用户体验是不太好的,如果给资源的加载过程以一个进度条的形式展示出来就是一个很好的解决办法。

进度条的加载方法如下:

game.MyStates.load = {
  preload: function() {
    //加载资源,加载完毕之后才会执行create方法
    var preloadSprite = game.add.sprite(10, game.height/2, 'loading');    //资源加载进度条
    game.load.setPreloadSprite(preloadSprite);
    game.load.image('background', 'assets/bg.jpg');
  },
  create: function() {
  	game.state.start('state1');
  }
}

比较遗憾的是,phaser目前还不支持显示GIF动画,所有的GIF图片都只显示第一帧。

另外我们还可以获取资源加载的进度:

game.load.onFileComplete.add(function(process) {
	//给每个加载动作都绑定一个回调函数
	//console.log(arguments);
	console.log(process);
});

我们可以给每个加载动作都绑定一个回调函数,可以通过arguments获取到一共要加载的资源数,已经加载的资源数等信息,非常方便。

屏幕适配

目前最火爆的当然是移动端,手机正在渐渐代替电脑的部分功能,很多东西都慢慢得适配了移动端,我们开发游戏也是一样。但是移动端的设备的屏幕分辨率并不是一样的,要是游戏全屏肯定是不能直接指定游戏的画布大小的,那么怎么解决呢?phaser早已想到了这一点。

game.MyStates.boot = {
  preload: function() {
    game.load.image('loading', 'assets/preloader.gif');
    if(!game.device.desktop) {    //如果不是电脑端,则适配屏幕
      game.scale.scaleMode = Phaser.ScaleManager.EXACT_FIT;   //完全填充
      //game.scale.scaleMode = Phaser.ScaleManager.SHOW_ALL;      //没有拉升变形,尽量填充整个屏幕
    }
  },
  create: function() {
    game.state.start('load');
  }
}

这里我又创建了一个在load前面的场景(当然图方便也可以不要这个场景),首先判断是不是PC端访问,如果不是,那么久完全填充整个屏幕,EXACT_FIT是拉伸填充屏幕,可能会因为长宽比的不同而使游戏画面有轻微变形,SHOW_ALL则是在保证原比例尺寸的前提下最大化填充整个屏幕,具体的选择看具体的项目需求。

裁判系统数据读取探索

RM备赛期间遇到需要读取裁判系统的数据,在此将学习过程记录下来。因为RM2019的裁判系统的通信协议还没有出来,所以我学习的是RM2018版的裁判系统的数据获取。

以下的代码都参考自RoboMaster官方代码,GitHub地址点这里

通信协议

先看看2018版裁判系统的通信协议。
点我查看通信协议

USART配置

我这里选用的是USART2,采用异步通信模式,并使能DMA,同时打开串口全局中断和DMA中断,波特率115200,数据位8位,停止位1位,无奇偶校验位。

因为裁判系统的数据不是定长的,所以采取串口空闲中断+DMA的方式读取,大致思路是设置很长的一个缓存区给DMA进行数据接收,然后在串口空闲中断中获取数据长度和信息,并采用队列的方法将此时DMA的数据位置保存为指针,方便下一次串口空闲中断时从上一次的位置继续读取数据。

串口空闲中断

串口空闲中断与普通的串口中断不同,它是在接收到一帧数据后才会进入一次中断。在单片机接收到一个字节的时候(RXNE位被置位)并不会产生串口中断,而是DMA在后台把数据默默地搬运到你指定的缓冲区里面。当整帧数据发送完毕之后(接收停顿超过一字节时间)串口才会产生一次中断。

串口空闲中断开启步骤:

__HAL_UART_CLEAR_IDLEFLAG(&huart2);
__HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);

上面两句话就开启了我的USART2的空闲中断(IDLE)

然后给我的串口加上DMA双缓冲模式。

SET_BIT(huart2.Instance->CR3, USART_CR3_DMAR);

DMAEx_MultiBufferStart_IT(huart2.hdmarx, \
                           (uint32_t)&huart2.Instance->DR, \
                           (uint32_t)judge_dma_rxbuff[0], \
                           (uint32_t)judge_dma_rxbuff[1], \
                           UART_RX_DMA_SIZE);

那么什么是DMA双缓冲模式呢?

DMA双缓冲模式

DMA双缓冲模式有以下特点:

  • 除了有两个存储器指针之外,双缓冲区数据流的工作方式与常规(单缓冲区)数据流的一样。
  • 使能双缓冲区模式时,将自动使能循环模式,并在每次事务结束时交换存储器指针。
  • DMA正在访问的当前存储区由DMA_SxCR表示

CT:当前目标
CT = 0:DMA正在访问存储区0,CPU可以访问存储区1
CT = 1:DMA正在访问存储区1,CPU可以访问存储区0

好处:

使用DMA双缓冲传输,既可以减少CPU的负荷,又能最大程度地实现DMA数据传输和CPU数据处理互不打扰又互不耽搁,DMA双缓冲模式的循环特性,使用它对存储区的空间容量要求也会大大降低。尤其在大批量数据传送时,你只需开辟两个合适大小的存储区,能满足DMA在切换存储区时的当前新存储区空出来就好,并不一定要开辟多大多深的存储空间,单纯一味地加大双缓冲区的深度并不明显改善数据传输状况。

引用自博客DMA双缓冲模式

而裁判系统的数据量很大,很适合用DMA双缓冲模式。另外我用上了FreeRTOS操作系统,把具体的解析函数放在线程里面,通过在中断中发送信号量来驱动线程。

好,现在来看上面的开启双缓冲的代码是怎么实现的。

  • 第一句:
SET_BIT(huart2.Instance->CR3, USART_CR3_DMAR);

USART中有CR1CR2CR3这三个寄存器,其中CR1来设置数据位是8位还是9位,CR2设置停止位,CR3设置DMA缓冲方式,而USART_CR3_DMAR代表的正好就是DMA使能,在HAL库中它的定义如下:

#define USART_CR3_DMAR     USART_CR3_DMAR_Msk                       /*!<DMA Enable Receiver         */
  • 第二句
DMAEx_MultiBufferStart_IT(huart2.hdmarx, \
                           (uint32_t)&huart2.Instance->DR, \
                           (uint32_t)judge_dma_rxbuff[0], \
                           (uint32_t)judge_dma_rxbuff[1], \
                           UART_RX_DMA_SIZE);

这里的judge_dma_rxbuffUART_RX_DMA_SIZE定义如下:

int UART_RX_DMA_SIZE = 1024;
uint8_t judge_dma_rxbuff[2][1024];

这里直接使用了长达1024的两个数组缓存裁判系统的发送回来的数据。

上面这个函数的定义如下:

static HAL_StatusTypeDef DMAEx_MultiBufferStart_IT(DMA_HandleTypeDef *hdma, \
                                                   uint32_t SrcAddress, \
                                                   uint32_t DstAddress, \
                                                   uint32_t SecondMemAddress, \
                                                   uint32_t DataLength)
{
  HAL_StatusTypeDef status = HAL_OK;
  
  /* Memory-to-memory transfer not supported in double buffering mode */
  if (hdma->Init.Direction == DMA_MEMORY_TO_MEMORY)
  {
    hdma->ErrorCode = HAL_DMA_ERROR_NOT_SUPPORTED;
    return HAL_ERROR;
  }
  
  /* Set the UART DMA transfer complete callback */
  /* Current memory buffer used is Memory 1 callback */
  hdma->XferCpltCallback   = dma_m0_rxcplt_callback;
  /* Current memory buffer used is Memory 0 callback */
  hdma->XferM1CpltCallback = dma_m1_rxcplt_callback;
  
  /* Check callback functions */
  if ((NULL == hdma->XferCpltCallback) || (NULL == hdma->XferM1CpltCallback))
  {
    hdma->ErrorCode = HAL_DMA_ERROR_PARAM;
    return HAL_ERROR;
  }
  
  /* Process locked */
  __HAL_LOCK(hdma);
  
  if(HAL_DMA_STATE_READY == hdma->State)
  {
    /* Change DMA peripheral state */
    hdma->State = HAL_DMA_STATE_BUSY;
    /* Initialize the error code */
    hdma->ErrorCode = HAL_DMA_ERROR_NONE;
    /* Enable the Double buffer mode */
    hdma->Instance->CR |= (uint32_t)DMA_SxCR_DBM;
    /* Configure DMA Stream destination address */
    hdma->Instance->M1AR = SecondMemAddress;
    
    /* Configure DMA Stream data length */
    hdma->Instance->NDTR = DataLength;
    /* Configure the source, destination address */
    if((hdma->Init.Direction) == DMA_MEMORY_TO_PERIPH)
    {
      hdma->Instance->PAR = DstAddress;
      hdma->Instance->M0AR = SrcAddress;
    }
    else
    {
      hdma->Instance->PAR = SrcAddress;
      hdma->Instance->M0AR = DstAddress;
    }
    
    /* Clear TC flags */
    __HAL_DMA_CLEAR_FLAG (hdma, __HAL_DMA_GET_TC_FLAG_INDEX(hdma));
    /* Enable TC interrupts*/
    hdma->Instance->CR  |= DMA_IT_TC;
    
    /* Enable the peripheral */
    __HAL_DMA_ENABLE(hdma);
    
    /* Change the DMA state */
    hdma->State = HAL_DMA_STATE_READY;
  }
  else
  {
    /* Return error status */
    status = HAL_BUSY;
  }
  
  /* Process unlocked */
  __HAL_UNLOCK(hdma);
  
  return status; 
}

好,现在USART2的串口空闲中断和DMA双缓冲模式都已经配置好了,接下来写中断函数。

串口空闲中断函数

这里我直接在stm32f4xx_it.c中修改中断函数。

void USART2_IRQHandler(void)
{
  /* USER CODE BEGIN USART2_IRQn 0 */
  uart_receive_handler(&huart2);
  /* USER CODE END USART2_IRQn 0 */
  HAL_UART_IRQHandler(&huart2);
  /* USER CODE BEGIN USART2_IRQn 1 */

  /* USER CODE END USART2_IRQn 1 */
}

上面的uart_receive_handle(&huart2)是我定义的串口空闲中断函数。它的实现如下:

void uart_receive_handler(UART_HandleTypeDef *huart)
{
  if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE) &&
      __HAL_UART_GET_IT_SOURCE(huart, UART_IT_IDLE))
  {
    uart_rx_idle_callback(huart);
  }
}

这里很简单,就是在中断里面判断是不是一帧数据已经发送完了,以及是不是由串口空闲中断引起的中断,如果是就执行串口空闲中断回调函数uart_rx_idle_callback(huart);

void uart_rx_idle_callback(UART_HandleTypeDef* huart)
{
  /* clear idle it flag avoid idle interrupt all the time */
  __HAL_UART_CLEAR_IDLEFLAG(huart);

  /* handle received data in idle interrupt */
	if (huart == &huart2) 
  {
    //osSignalSet(judge_unpack_task_t, JUDGE_UART_IDLE_SIGNAL);
    //HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_14);
    osSemaphoreRelease(JudgementSignalHandle);
  }

}

上面的代码也很简单,首先清楚中断标志位,然后发送一个信号量,通知对应的操作系统线程开始运行。

数据存储

现在就进入线程里面把数据存储下来了。

void JudgementReceive(void const * argument)
{

  /* USER CODE BEGIN JudgementReceive */
  judgement_uart_init();
  /* Infinite loop */
  for(;;)
  {
    osSemaphoreWait(JudgementSignalHandle, osWaitForever);
    HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_14);
    JudgementDataReceive();
  }
  /* USER CODE END JudgementReceive */
}

终于看到最重要的处理函数JudgementDataReceive了,然后我在这里顺便闪灯指示。

但是!在看这个函数执行之前,需要执行一个初始化函数,我把它写在main函数里面,在操作系统初始化之前执行。

int main()
{
	//外设初始化语句...
	communicate_param_init();
  while(1)
  {
    //you should not write any code here...
  }
}

这个函数叫做communicate_param_init();,它的具体内容如下:

void communicate_param_init(void)
{
  /* judge data fifo init */
  fifo_s_init(&judge_rxdata_fifo, judge_rxdata_buf, JUDGE_FIFO_BUFLEN);
  fifo_s_init(&judge_txdata_fifo, judge_txdata_buf, JUDGE_FIFO_BUFLEN);


  /* initial judge data dma receiver object */
  judge_rx_obj.huart = &huart2;
  judge_rx_obj.data_fifo = &judge_rxdata_fifo;
  judge_rx_obj.buff_size = UART_RX_DMA_SIZE;
  judge_rx_obj.buff[0] = judge_dma_rxbuff[0];
  judge_rx_obj.buff[1] = judge_dma_rxbuff[1];


  /* initial judge data unpack object */
  judge_unpack_obj.data_fifo = &judge_rxdata_fifo;
  judge_unpack_obj.p_header = (frame_header_t *)judge_unpack_obj.protocol_packet;
  judge_unpack_obj.index = 0;
  judge_unpack_obj.data_len = 0;
  judge_unpack_obj.unpack_step = STEP_HEADER_SOF;  

}

上面的函数主要有三部分,第一部分是裁判系统发送过来的数据以及我们发送给裁判系统的数据的队列*(FIFO)*。第二部分是用于存储裁判系统数据的结构体,第三部分是用于解析裁判系统数据的结构体。下面一部分一部分来看:

FIFO

先看看上面那个函数的实现:

int32_t fifo_s_init(fifo_s_t* pfifo, void* base_addr, uint32_t unit_cnt, osMutexId mutex)
{
  if (mutex != NULL)
  {
    //! Initialize FIFO Control Block.
    pfifo->start_addr  = (uint8_t*) base_addr;
    pfifo->end_addr    = (uint8_t*) base_addr + unit_cnt - 1;
    pfifo->buf_size    = unit_cnt;
    pfifo->free        = unit_cnt;
    pfifo->used        = 0;
    pfifo->read_index  = 0;
    pfifo->write_index = 0;
    return 0;
  }
  else
  {
    return -1;
  }
}

这其中涉及到一个结构体:

typedef struct
{
  uint8_t   *start_addr;                   //Start Address
  uint8_t   *end_addr;                     //End Address
  uint32_t  free;                         //The capacity of FIFO
  uint32_t  buf_size;                     //Buffer size
  uint32_t  used;                         //The number of elements in FIFO
  uint8_t   read_index;                   //Read Index Pointer
  uint8_t   write_index;                  //Write Index Pointer
} fifo_s_t;

这里用到了C语言的队列功能*(FIFO)*。定义了两个队列judge_rxdata_fifojudge_txdata_fifo

FIFO,即先入先出(first input first output),具体的还不太懂,待后续更新。


回到前面的函数,第二部分是关于一个叫judge_rx_obj的结构体,该结构体的定义如下:

typedef struct
{
  UART_HandleTypeDef *huart;
  fifo_s_t           *data_fifo;
  uint16_t           buff_size;
  uint8_t            *buff[2];
  uint16_t           read_index;
  uint16_t           write_index;
} uart_dma_rxdata_t;

uart_dma_rxdata_t judge_rx_obj;

在上面的函数中将这个结构体与对应的接收消息的队列绑在了一起。

第三部分也是一个结构体,从名字推测应该是数据解析时所使用的结构体。

typedef struct
{
  fifo_s_t       *data_fifo;
  frame_header_t *p_header;
  uint16_t       data_len;
  uint8_t        protocol_packet[PROTOCAL_FRAME_MAX_SIZE];
  unpack_step_e  unpack_step;
  uint16_t       index;
} unpack_data_t;

unpack_data_t judge_unpack_obj;

它也与接收消息的队列judgement_rxdata_fifo联系在一起。其中的frame_header_t定义如下:

typedef __packed struct
{
  uint8_t  sof;
  uint16_t data_length;
  uint8_t  seq;
  uint8_t  crc8;
} frame_header_t;

这是裁判系统的FrameHeader帧的格式。

上面的函数初始化了数据队列,指定了队列的起始地址和结束地址,缓冲内存大小,剩余内存大小和读写数据的位。

另外该函数还配置了两个结构体judge_rx_objjudge_unpack_obj的一些选项,judge_rx_obj结构体很好理解,就是指定串口号,数据存储的队列,缓冲大小和DMA双缓冲对应的两个缓冲数组。

第二个结构体judge_unpack_obj首先指定了数据队列,然后将队列的初始指针指向了FrameHeader帧的帧头,数组序号设为0,数据长度设为0,解析步骤设为第一步STEP_HEADER_SOF

解析的步骤定义在了下面的枚举量中:

typedef enum
{
  STEP_HEADER_SOF  = 0,
  STEP_LENGTH_LOW  = 1,
  STEP_LENGTH_HIGH = 2,
  STEP_FRAME_SEQ   = 3,
  STEP_HEADER_CRC8 = 4,
  STEP_DATA_CRC16  = 5,
} unpack_step_e;

好,到现在为止所有的初始化步骤(串口配置,DMA配置,数组、队列、结构体配置)均已完成了,现在终于可以进入前面遇到的JudgementDataReceive函数了。

数据解析

现在贴出这个函数的内容:

void JudgementDataReceive()
{
  dma_buffer_to_unpack_buffer(&judge_rx_obj, UART_IDLE_IT);
  unpack_fifo_data(&judge_unpack_obj, DN_REG_ID);
}

这里面有两个函数,从函数名可以看出第一个函数是将DMA储存的数据转移到解析用的缓存中,第二个函数是解析数据的具体逻辑。

先看第一个函数:

dma_buffer_to_unpack_buffer(&judge_rx_obj, UART_IDLE_IT);

这里有一个变量UART_IDLE_IT,它被定义在下面的枚举量汇中:

typedef enum
{
  UART_IDLE_IT     = 0,
  UART_DMA_HALF_IT = 1,
  UART_DMA_FULL_IT = 2,
} uart_it_type_e;

这里可以看出定义的是几种串口的中断模式,因为这里借鉴的官方的开源代码,那里面会涉及到好几种中断模式,所以定义了这个枚举量,其实单独使用的话是不需要定义这么多的。

这个函数的函数体如下:

void dma_buffer_to_unpack_buffer(uart_dma_rxdata_t *dma_obj, uart_it_type_e it_type)
{
  int16_t  tmp_len;
  uint8_t  current_memory_id;
  uint16_t remain_data_counter;
  uint8_t  *pdata = dma_obj->buff[0];

  get_dma_memory_msg(dma_obj->huart->hdmarx->Instance, &current_memory_id, &remain_data_counter);

  if (UART_IDLE_IT == it_type)
  {
    if (current_memory_id)
    {
      dma_obj->write_index = dma_obj->buff_size*2 - remain_data_counter;
    }
    else
    {
      dma_obj->write_index = dma_obj->buff_size - remain_data_counter;
    }
  }
  else if (UART_DMA_FULL_IT == it_type)
  {
#if 0
    if (current_memory_id)
    {
      dma_obj->write_index = dma_obj->buff_size;
    }
    else
    {
      dma_obj->write_index = dma_obj->buff_size*2;
    }
#endif
  }

  if (dma_obj->write_index < dma_obj->read_index)
  {
    dma_write_len = dma_obj->buff_size*2 - dma_obj->read_index + dma_obj->write_index;    //这里为啥*2还没搞懂

    tmp_len = dma_obj->buff_size*2 - dma_obj->read_index;
    if (tmp_len != fifo_s_puts(dma_obj->data_fifo, &pdata[dma_obj->read_index], tmp_len))
      fifo_overflow = 1;
    else
      fifo_overflow = 0;
    dma_obj->read_index = 0;

    tmp_len = dma_obj->write_index;
    if (tmp_len != fifo_s_puts(dma_obj->data_fifo, &pdata[dma_obj->read_index], tmp_len))
      fifo_overflow = 1;
    else
      fifo_overflow = 0;
    dma_obj->read_index = dma_obj->write_index;
  }
  else
  {
    dma_write_len = dma_obj->write_index - dma_obj->read_index;

    tmp_len = dma_obj->write_index - dma_obj->read_index;
    if (tmp_len != fifo_s_puts(dma_obj->data_fifo, &pdata[dma_obj->read_index], tmp_len))
      fifo_overflow = 1;
    else
      fifo_overflow = 0;
    dma_obj->read_index = (dma_obj->write_index) % (dma_obj->buff_size*2);
  }
}

上面的代码中,已经把除串口空闲中断的其他中断对应的逻辑都注释掉了。

其中有一个获取DMA的缓冲通道和缓冲的数据长度的函数:

void get_dma_memory_msg(DMA_Stream_TypeDef *dma_stream, uint8_t *mem_id, uint16_t *remain_cnt)
{
  *mem_id     = dma_current_memory_target(dma_stream);
  *remain_cnt = dma_current_data_counter(dma_stream);
}

uint8_t dma_current_memory_target(DMA_Stream_TypeDef *dma_stream)
{
  uint8_t tmp = 0;

  /* Get the current memory target */
  if ((dma_stream->CR & DMA_SxCR_CT) != 0)
  {
    /* Current memory buffer used is Memory 1 */
    tmp = 1;
  }
  else
  {
    /* Current memory buffer used is Memory 0 */
    tmp = 0;
  }
  return tmp;
}

uint16_t dma_current_data_counter(DMA_Stream_TypeDef *dma_stream)
{
  /* Return the number of remaining data units for DMAy Streamx */
  return ((uint16_t)(dma_stream->NDTR));
}

DMA的寄存器中,表示DMA缓冲通道的为DMA_SxCR_CT,取值为0和1,NDTR表示的是DMA缓冲内存中还有多少字节剩余。

DMA的寄存器中,表示DMA缓冲通道的为DMA_SxCR_CT,取值为0和1,NDTR表示的是DMA缓冲内存中还有多少字节剩余。

在上面的代码中有这样的一段:

tmp_len = dma_obj->buff_size*2 - dma_obj->read_index;
if (tmp_len != fifo_s_puts(dma_obj->data_fifo, &pdata[dma_obj->read_index], tmp_len))
	fifo_overflow = 1;
else
	fifo_overflow = 0;
dma_obj->read_index = 0;

tmp_len = dma_obj->write_index;
if (tmp_len != fifo_s_puts(dma_obj->data_fifo, &pdata[dma_obj->read_index], tmp_len))
	fifo_overflow = 1;
else
	fifo_overflow = 0;
dma_obj->read_index = dma_obj->write_index;

这其中的fifo_s_puts函数就是将一个队列转移到另一个队列的操作,它的函数定义如下:

/****************************************
//
//! \brief  Put some elements into FIFO(in single mode).
//!
//! \param  [in]  pfifo is the pointer of valid FIFO.
//! \param  [in]  element is the data element you want to put
//! \param  [in]  the number of elements
//! \retval 0 if operate successfully, otherwise return -1.
//
*****************************************/
int32_t fifo_s_puts(fifo_s_t *pfifo, uint8_t *psource, uint32_t number)
{
  int puts_num = 0;

  if(psource == NULL)
      return -1;

  for(uint32_t i = 0; (i < number) && (pfifo->free > 0); i++)
  {
    pfifo->start_addr[pfifo->write_index++] = psource[i];
    pfifo->write_index %= pfifo->buf_size;
    pfifo->free--;
    pfifo->used++;
    puts_num++;
  }

  return puts_num;
}

看到这里,缓冲数据转移基本就梳理清晰了,接下来是这整个工程中最最最重要的地方——解析裁判系统的数据!

先从最外层的调用函数开始看:

unpack_fifo_data(&judge_unpack_obj, DN_REG_ID);

嘿嘿,执行到这里的时候,judge_unpack_obj里面已经有数据啦*(因为前面调用函数fifo_s_puts将数据拷贝放进了judge_rx_obj->data_fifo中,而judge_rx_objjudge_unpack_objdata_fifo是指向同一个队列的)*。

那么这里有一个变量DN_REG_ID,它是一个宏定义:

#define DN_REG_ID    0xA5

这其实就是裁判系统的FrameHeader帧的SOF起始字节,固定值为0xA5。

好,下面看看这个解析函数的具体实现:

void unpack_fifo_data(unpack_data_t *p_obj, uint8_t sof)
{
  uint8_t byte = 0;

  while ( fifo_used_count(p_obj->data_fifo) )
  {
    byte = fifo_s_get(p_obj->data_fifo);
    switch(p_obj->unpack_step)
    {
      case STEP_HEADER_SOF:
      {
        if(byte == sof)
        {
          p_obj->unpack_step = STEP_LENGTH_LOW;
          p_obj->protocol_packet[p_obj->index++] = byte;
        }
        else
        {
          p_obj->index = 0;
        }
      }break;

      case STEP_LENGTH_LOW:
      {
        p_obj->data_len = byte;
        p_obj->protocol_packet[p_obj->index++] = byte;
        p_obj->unpack_step = STEP_LENGTH_HIGH;
      }break;

      case STEP_LENGTH_HIGH:
      {
        p_obj->data_len |= (byte << 8);
        p_obj->protocol_packet[p_obj->index++] = byte;

        if(p_obj->data_len < (PROTOCAL_FRAME_MAX_SIZE - HEADER_LEN - CRC_LEN))
        {
          p_obj->unpack_step = STEP_FRAME_SEQ;
        }
        else
        {
          p_obj->unpack_step = STEP_HEADER_SOF;
          p_obj->index = 0;
        }
      }break;

      case STEP_FRAME_SEQ:
      {
        p_obj->protocol_packet[p_obj->index++] = byte;
        p_obj->unpack_step = STEP_HEADER_CRC8;
      }break;

      case STEP_HEADER_CRC8:
      {
        p_obj->protocol_packet[p_obj->index++] = byte;

        if (p_obj->index == HEADER_LEN)
        {
          if ( verify_crc8_check_sum(p_obj->protocol_packet, HEADER_LEN) )
          {
            p_obj->unpack_step = STEP_DATA_CRC16;
          }
          else
          {
            p_obj->unpack_step = STEP_HEADER_SOF;
            p_obj->index = 0;
          }
        }
      }break;

      case STEP_DATA_CRC16:
      {
        if (p_obj->index < (HEADER_LEN + CMD_LEN + p_obj->data_len + CRC_LEN))
        {
           p_obj->protocol_packet[p_obj->index++] = byte; 
        }
        if (p_obj->index >= (HEADER_LEN + CMD_LEN + p_obj->data_len + CRC_LEN))
        {
          p_obj->unpack_step = STEP_HEADER_SOF;
          p_obj->index = 0;

          if ( verify_crc16_check_sum(p_obj->protocol_packet, HEADER_LEN + CMD_LEN + p_obj->data_len + CRC_LEN) )
          {

				if (sof == DN_REG_ID)//DN_REG_ID
            {
              judgement_data_handler(p_obj->protocol_packet);
            }
          }
        }
      }break;

      default:
      {
        p_obj->unpack_step = STEP_HEADER_SOF;
        p_obj->index = 0;
      }break;
    }
  }
}

又是这么长的函数呀,没事,这已经是最后一关了,而且我已经一眼看见裁判系统的最终最终的解析函数judgement_data_handler(p_obj->protocol_packet);

首先看看函数fifo_used_count

uint32_t fifo_used_count(fifo_s_t* pfifo)
{
  return (pfifo->used);
}

这个函数返回的是队列中已经使用的字节数,即存储了多少字节的数据。

接下来的函数是fifo_s_get

uint8_t fifo_s_get(fifo_s_t* pfifo)
{
  uint8_t   retval = 0;

  retval = pfifo->start_addr[pfifo->read_index++];
  pfifo->read_index %= pfifo->buf_size;
  pfifo->free++;
  pfifo->used--;

  return retval;
}

显然这个函数不断调用,每次都会将指针往后移,依次把队列中的数据赋值给局部变量byte,接下来就简单了,只需要按照裁判系统的通信协议就能理解了。

最后把裁判系统的数据解析函数列出来,就不用写解释了。

void judgement_data_handler(uint8_t *p_frame)
{
  frame_header_t *p_header = (frame_header_t*)p_frame;
  memcpy(p_header, p_frame, HEADER_LEN);

  uint16_t data_length = p_header->data_length;
  uint16_t cmd_id      = *(uint16_t *)(p_frame + HEADER_LEN);
  uint8_t *data_addr   = p_frame + HEADER_LEN + CMD_LEN;

  switch (cmd_id)
  {
    case GAME_INFO_ID:
      memcpy(&judge_rece_mesg.game_information, data_addr, data_length);
    break;

    case REAL_BLOOD_DATA_ID:
      memcpy(&judge_rece_mesg.blood_changed_data, data_addr, data_length);
    break;

    case REAL_SHOOT_DATA_ID:
      memcpy(&judge_rece_mesg.real_shoot_data, data_addr, data_length);
    break;

		case REAL_CHESS_POWER_ID:
      memcpy(&judge_rece_mesg.power_heatdata, data_addr, data_length);
    break;

    case REAL_FIELD_DATA_ID:
      memcpy(&judge_rece_mesg.rfid_data, data_addr, data_length);
    break;

    case GAME_RESULT_ID:
      memcpy(&judge_rece_mesg.game_result_data, data_addr, data_length);
    break;

    case GAIN_BUFF_ID:
      memcpy(&judge_rece_mesg.get_buff_data, data_addr, data_length);
    break;

    case Robo_Postion_ID:
      memcpy(&judge_rece_mesg.gameRobotPos, data_addr, data_length);
    break;
  }
}

裁判系统数据的结构体定义如下:

/** 
  * @brief  judgement data command id
  */
typedef enum
{
  GAME_INFO_ID       = 0x0001,  //10Hz
  REAL_BLOOD_DATA_ID = 0x0002,
  REAL_SHOOT_DATA_ID = 0x0003,
	REAL_CHESS_POWER_ID= 0x0004,
  REAL_FIELD_DATA_ID = 0x0005,  //10hZ
  GAME_RESULT_ID     = 0x0006,
  GAIN_BUFF_ID       = 0x0007,
  Robo_Postion_ID			=0X0008,
	
  STU_CUSTOM_DATA_ID = 0x0100,//ÉÏ´«ID

} judge_data_id_e;


/** 
  * @brief  game information structures definition(0x0001)
  *         this package send frequency is 50Hz
  */

typedef __packed struct
{
  uint16_t   stage_remain_time;
  uint8_t    game_process;
  /* current race stage
   0 not start
   1 preparation stage
   2 self-check stage
   3 5 seconds count down
   4 fighting stage
   5 result computing stage */
  uint8_t    robotlevel;
  uint16_t   remain_hp;
  uint16_t   max_hp;  
} game_robot_state_t;

/** 
  * @brief  real time blood volume change data(0x0002)
  */
typedef __packed struct
{
  uint8_t armor_type:4;
 /* 0-3bits: the attacked armor id:
    0x00: 0 front
    0x01:1 left
    0x02:2 behind
    0x03:3 right
    others reserved*/
  uint8_t hurt_type:4;
 /* 4-7bits: blood volume change type
    0x00: armor attacked
    0x01:module offline
    0x02: bullet over speed
    0x03: bullet over frequency */
} robot_hurt_data_t;

/** 
  * @brief  real time shooting data(0x0003)
  */
typedef __packed struct
{
  uint8_t bullet_type;
  uint8_t bullet_freq;
  float   bullet_spd;
} real_shoot_t;
/** 
  * @brief  rfid detect data(0x0004)
  */
typedef __packed struct
{
  float chassisVolt;
	float chassisCurrent;
	float chassisPower;
	float chassisBuffer;
	uint16_t shooterHeat0;
	uint16_t shooterHeat1;
} power_heatdata_t;


/** 
  * @brief  rfid detect data(0x0005)
  */
typedef __packed struct
{
  uint8_t card_type;
  uint8_t card_idx;
} rfid_detect_t;

/** 
  * @brief  game result data(0x0006)
  */
typedef __packed struct
{
  uint8_t winner;
} game_result_t;

/** 
  * @brief  the data of get field buff(0x0007)
  */
typedef __packed struct
{
  uint8_t buff_type;
  uint8_t buff_addition;
} get_buff_t;
/** 
  * @brief  the data of get field buff(0x0008)
  */
typedef __packed struct
{
  float x;
	float y;
	float z;
	float yaw;
} gameRobotPos_t;

/** 
  * @brief  the data structure receive from judgement
  */
typedef struct
{
  game_robot_state_t game_information;	//0x001
  robot_hurt_data_t  blood_changed_data;//0x002
  real_shoot_t       real_shoot_data;		//0x003
	power_heatdata_t 	 power_heatdata;		//0x004
  rfid_detect_t      rfid_data;					//0x005
  game_result_t      game_result_data;	//0x006
  get_buff_t         get_buff_data;			//0x007
	gameRobotPos_t		 gameRobotPos;			//0x008
 
} receive_judge_t;

receive_judge_t judge_rece_mesg;

哇,真的是兴奋!今天又学到了新知识!

网站搜索功能

随着博客写的越来越多,寻找内容越来越麻烦,于是萌生出了做一个文章搜索的功能。

GitHub API

在查阅了GitHub API后,发现可以很方便地搜索issue,repository,commit,author等,还支持多条件查询,可以说是非常方便了,详情见Search | GitHub Developer Guide

我这里只需要查询我自己发布的issue,这里的查询条件有以下五个:

  • 查询关键词
  • 作者:imuncle
  • 对象:open状态的issue
  • 范围:标题和内容
  • 排序:匹配程度递减排序

所以我的查询链接为:

var search_url = 'https://api.github.com/search/issues?q='+search+' author:imuncle+in:title,body+is:open+is:public+sort:interactions';

关于以上的查询条件设置可以查看这两个链接了解:

UI设计

具体的UI实现就在小站的每个页面的右上角,位于菜单按钮下方,实现代码如下:

  • HTML
<input type="text" class="search-input" placeholder="回车搜索" onblur="searchOnblur()">
<img src="images/search.svg" class="search">
  • css
.search {
    opacity: 0.6;
    position: fixed;
    right: 20px;
    top: 60px;
    cursor: pointer;
    z-index: 99;
    width: 35px;
    transition: all 0.4s;
}
.search-input {
    width: 42px;
    height: 42px;
    padding-left: 15px;
    border-radius: 42px;
    border: 1px solid rgba(127,140,141,0.6);
    background: white;
    outline: none;
    position: relative;
    transition: all 0.4s;
    position: fixed;
    right: 10px;
    top: 57px;
    z-index: -1;
}
  • javascript
$('.search').click(function() {
    $(".search-input").css('z-index',99);
    $(".search-input").css("width",'300px');
    $(".search-input").focus();
});

function searchOnblur(){
    if($('.search-input').val() == "")
    {
        $(".search-input").css("width",'42px');
        $(".search-input").css("z-index",-1);
    }
}

$('.search-input').bind('keypress', function (event) { 
    if (event.keyCode == "13" && $('.search-input').val() != "") {
        window.location.href = 'issue_per_label.html?q='+$('.search-input').val();
    }
})

关键词高亮

开始我只高亮了完全匹配搜索关键词的地方,效果如下:
image

可以看到除了前两条搜索结果,后面的搜索结果似乎都没有出现步进电机这个关键词,仔细阅读其他搜索结果发现文章中出现了步进电机中的某一个或几个字,导致被检索到,所以我决定把搜索的关键词拆分成单个字,挨个高亮。

顺便一提,GitHub搜索是支持多个关键词的,只需要空格分开就行了,所以高亮之前要把搜索关键词中的空格删掉。

具体的实现代码如下:

var html = document.getElementById('issue-list').innerHTML;
search = search.replace(/\s*/g,"");
for(var i=0;i<search.length;i++) {
   var newHtml = html.replaceAll(search[i],'<font style="background-color:yellow;">'+search[i]+'</font>');
   document.getElementById('issue-list').innerHTML = newHtml;
}

修改之后的效果如下:
image

步兵车底盘驱动

底盘作为一个机器人最基础的东西,是各位必须掌握的。但是看着大家的任务完成进度感到焦心,所以写了这篇步兵车底盘驱动代码的详解,以及底盘调试的步骤和要点。

工程简介

  • 这篇教程里的代码使用STM32F405RGT6芯片,使用STM32CubeMX软件辅助进行开发。
  • 使用的电机是由大疆创新生产的3508无刷电机,搭配C620电子调速器,使用CAN通信进行控制。
  • 使用的遥控器是DT7 Robomaster比赛专用遥控器,遥控器接收机为DR16。
  • 底盘采用麦克纳姆轮全向底盘,麦克纳姆轮为“O-长方形”型安装方式,四个电机的ID号位置分布如下(底盘正前方朝上):
左上 1	2 右上
左下 4	3 右下

关于麦克纳姆轮的介绍可以查看这个视频的讲解,浅显易懂。

麦克纳姆轮的运动解析计算可以参考这篇博客【学渣的自我修养】麦克纳姆轮浅谈,其实麦克纳姆轮底盘就是一个简单的运动合成,可以参考下面的代码进行理解。

遥控器通信协议

遥控器采用的是DBUS协议,需要搭配硬件对应的取反电路才能正常使用。单片机通过与接收机的串口通信获取遥控器数据。遥控器每14ms发送一个18字节的数据,在遥控器的说明书中可以看到相关的数据解析函数:

Typedef __packed struct
{
	struct
	{
		uint16_t ch0;
		uint16_t ch1;
		uint16_t ch2;
		uint16_t ch3;
		uint8_t s1;
		uint8_t s2;
	}rc;
	struct
	{
		int16_t x;
		int16_t y;
		int16_t z;
		uint8_t press_l;
		uint8_t press_r;
	}mouse;
	struct
	{
		uint16_t v;
	}key;
}RC_Ctl_t;

void RemoteDataProcess(uint8_t *pData)
{
	if(pData == NULL)
	{
		return;
	}

	RC_CtrlData.rc.ch0 = ((int16_t)pData[0] | ((int16_t)pData[1] << 8)) & 0x07FF;
	RC_CtrlData.rc.ch1 = (((int16_t)pData[1] >> 3) | ((int16_t)pData[2] << 5))
	& 0x07FF;
	RC_CtrlData.rc.ch2 = (((int16_t)pData[2] >> 6) | ((int16_t)pData[3] << 2) |
	((int16_t)pData[4] << 10)) & 0x07FF;
	RC_CtrlData.rc.ch3 = (((int16_t)pData[4] >> 1) | ((int16_t)pData[5]<<7)) &
	0x07FF;

	RC_CtrlData.rc.s1 = ((pData[5] >> 4) & 0x000C) >> 2;
	RC_CtrlData.rc.s2 = ((pData[5] >> 4) & 0x0003);
	RC_CtrlData.mouse.x = ((int16_t)pData[6]) | ((int16_t)pData[7] << 8);
	RC_CtrlData.mouse.y = ((int16_t)pData[8]) | ((int16_t)pData[9] << 8);
	RC_CtrlData.mouse.z = ((int16_t)pData[10]) | ((int16_t)pData[11] << 8);
	RC_CtrlData.mouse.press_l = pData[12];
	RC_CtrlData.mouse.press_r = pData[13];
	RC_CtrlData.key.v = ((int16_t)pData[14]) | ((int16_t)pData[15] << 8);
	//your control code ….
}

上述的数据解析函数有点复杂,全是各种移位与或操作,对解码过程感兴趣的可以查看这篇文章RM2016DBUS协议完全解析

电机通信协议

首先要明白,我们是通过CAN通信发送信息给电调,然后由电调自己控制电机转动的。
从C620的说明书中可以找到它的通信协议,分为发送数据的通信协议以及反馈信息的通信协议。

发送数据

对于ID号为1-4号的电机,通信协议如下:

标识符 帧格式 帧类型 DLC
0x200 DATA 标准帧 8字节
数据域 内容 电调ID
DATA[0] 控制电流值高8位 1
DATA[1] 控制电流值低8位
DATA[2] 控制电流值高8位 2
DATA[3] 控制电流值低8位
DATA[4] 控制电流值高8位 3
DATA[5] 控制电流值低8位
DATA[6] 控制电流值高8位 4
DATA[7] 控制电流值低8位

对于ID号为5-8的电机,通信协议如下:

标识符 帧格式 帧类型 DLC
0x1FF DATA 标准帧 8字节
数据域 内容 电调ID
DATA[0] 控制电流值高8位 1
DATA[1] 控制电流值低8位
DATA[2] 控制电流值高8位 2
DATA[3] 控制电流值低8位
DATA[4] 控制电流值高8位 3
DATA[5] 控制电流值低8位
DATA[6] 控制电流值高8位 4
DATA[7] 控制电流值低8位

控制电流值范围为-16384-0-16384,对应电调输出的转矩电流范围为-20-0-20A。

电调反馈报文格式

标识符 0x200+电调ID号
如ID为1,则标识符为201
帧类型 标准帧
帧格式 DATA
DLC 8字节
数据域 内容
DATA[0] 转子机械角度高8位
DATA[1] 转子机械角度低8位
DATA[2] 转子转速高8位
DATA[3] 转子转速低8位
DATA[4] 实际转矩电流高8位
DATA[5] 实际转矩电流低8位
DATA[6] 电机温度
DATA[7] Null
发送频率 1KHz
转子机械角度值范围 0-8191(对应转子机械角度0-360°)
转子转速单位 RPM
电机温度单位

遥控器数据接收

遥控器数据是通过串口通信接收的,根据遥控器说明书,要设置串口波特率为100K。遥控器数据接收相关语句如下:

struct Remote
{
	int16_t ch0;
	int16_t ch1;
	int16_t ch2;
	int16_t ch3;
	int8_t s1;
	int8_t s2;
};

struct Mouse
{
	int16_t x;
	int16_t y;
	int16_t z;
	uint8_t press_l;
	uint8_t press_r;
};

struct Key
{
	uint16_t v;
};

struct DT7Remote
{
	struct Remote rc;
	struct Mouse mouse;
	struct Key key;
};

struct DT7Remote remote;	//储存遥控器解码后的数据

uint8_t rc_data[18];	//遥控器原始数据

void RemoteReceiveHandle(void)
{
	Remote.rc.ch0 = ((int16_t)rc_data[0] | ((int16_t)rc_data[1] << 8)) & 0x07FF;
	Remote.rc.ch1 = (((int16_t)rc_data[1] >> 3) | ((int16_t)rc_data[2] << 5)) & 0x07FF;
	Remote.rc.ch2 = (((int16_t)rc_data[2] >> 6) | ((int16_t)rc_data[3] << 2) | ((int16_t)rc_data[4] << 10)) & 0x07FF;
	Remote.rc.ch3 = (((int16_t)rc_data[4] >> 1) | ((int16_t)rc_data[5]<<7)) & 0x07FF;

	Remote.rc.s1 = ((rc_data[5] >> 4) & 0x000C) >> 2;
	Remote.rc.s2 = ((rc_data[5] >> 4) & 0x0003);

	Remote.mouse.x = ((int16_t)rc_data[6]) | ((int16_t)rc_data[7] << 8);
	Remote.mouse.y = ((int16_t)rc_data[8]) | ((int16_t)rc_data[9] << 8);
	Remote.mouse.z = ((int16_t)rc_data[10]) | ((int16_t)rc_data[11] << 8);

	Remote.mouse.press_l = rc_data[12];
	Remote.mouse.press_r = rc_data[13];

	Remote.key.v = ((int16_t)rc_data[14]);
}

int main()
{
	//外设初始化函数
	HAL_UART_Receive_DMA(&huart1, rc_data, 18u);	//使能遥控器串口接收
}

/**
* @brief 串口接收中断回调函数
* @param 串口号
* @retval None
*/
void HAL_UART_RxCpltCallback (UART_HandleTypeDef *huart)
{
	if(huart == &huart1)          //串口1为遥控器的数据接收口
	{
		HAL_UART_Receive_DMA(&huart1, rc_data, 18u);
		RemoteReceiveHandle();      //遥控器数据的具体解析函数
	}
}

学到这个程度了,上面的代码大家应该一遍下来都能看懂。

代码最开始定义了一系列关于遥控器数据的结构体,分别储存了手持遥控器的数据、电脑鼠标的移动数据和电脑键盘的按键数据。

然后定义了用于串口接收的变量rc_data。这里使用的是DMA的方式进行串口接收。因为遥控器每隔14ms发送了一个18字节长的数据,通过HAL_UART_Receive_DMA(&huart1, rc_data, 18u);语句使能遥控器串口接收。

在串口的中断函数里面,进行遥控器的数据处理。当中的处理逻辑完全是照搬遥控器说明书里面的。

将上面的代码写入工程后,进入调试界面,查看remote结构器,不出意外的话,就能看见里面的遥控器数据。

一般来说只能看到remote.rc结构体里面有数据,因为接收电脑数据需要电脑上安装对应的软件,并且使用数据线将遥控器和电脑连接起来,这样电脑的数据可以通过遥控器发送出去。

电机数据接收

步兵车底盘3508电机可以通过CAN通信获取到电机的数据。获取数据的步骤如下:

uint8_t CanReceiveData[8];	//电机反馈的原始数据

struct CAN_Motor
{
	int fdbPosition;        //电机的编码器反馈值
	int last_fdbPosition;   //电机上次的编码器反馈值
	int bias_position;      //机器人初始状态电机位置环设定值
	int fdbSpeed;           //电机反馈的转速/rpm
	int round;              //电机转过的圈数
	int real_position;      //过零处理后的电机转子位置
};

struct CAN_Motor m3508_1;	//底盘四个电机的数据存储结构体
struct CAN_Motor m3508_2;
struct CAN_Motor m3508_3;
struct CAN_Motor m3508_4;

/*
 * @brief CAN外设过滤器初始化
 * @param can结构体
 * @retval None
 */
HAL_StatusTypeDef CanFilterInit(CAN_HandleTypeDef* hcan)
{
	CAN_FilterTypeDef  sFilterConfig;

	sFilterConfig.FilterBank = 0;
	sFilterConfig.FilterMode = CAN_FILTERMODE_IDMASK;
	sFilterConfig.FilterScale = CAN_FILTERSCALE_32BIT;
	sFilterConfig.FilterIdHigh = 0x0000;
	sFilterConfig.FilterIdLow = 0x0000;
	sFilterConfig.FilterMaskIdHigh = 0x0000;
	sFilterConfig.FilterMaskIdLow = 0x0000;
	sFilterConfig.FilterFIFOAssignment = CAN_RX_FIFO0;
	sFilterConfig.FilterActivation = ENABLE;
	sFilterConfig.SlaveStartFilterBank = 14;

	if(hcan == &hcan1)
	{
		sFilterConfig.FilterBank = 0;
	}
	if(hcan == &hcan2)
	{
		sFilterConfig.FilterBank = 14;
	}

	if(HAL_CAN_ConfigFilter(hcan, &sFilterConfig) != HAL_OK)
	{
		Error_Handler();
	}

	if (HAL_CAN_Start(hcan) != HAL_OK)
	{
		Error_Handler();
	}

	if (HAL_CAN_ActivateNotification(hcan, CAN_IT_RX_FIFO0_MSG_PENDING) != HAL_OK)
	{
		Error_Handler();
	}

	return HAL_OK;
}

int main()
{
	//初始化外设代码省略
	CanFilterInit(&hcan1);          //初始化CAN1过滤器
}

/*
 * @brief CAN通信接收中断回调函数
 * @param CAN序号
 * @retval None
 */
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
	CAN_RxHeaderTypeDef   RxHeader;
	if(HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &RxHeader, CanReceiveData) != HAL_OK)
	{
		Error_Handler();            //如果CAN通信数据接收出错,则进入死循环
	}
	CanDataReceive(RxHeader.StdId);   //进行电机数据解析
}

/*
 * @brief 根据电机信息的ID号进行对应的数据解析
 * @param 电机ID号
 * @retval None
 */
void CanDataReceive(int motor_index)
{
	switch(motor_index)
	{
		case CAN_CHASSIS_MOTOR1_ID:
			CanDataEncoderProcess(&m3508_1);break;    //电机数据具体解析函数
		case CAN_CHASSIS_MOTOR2_ID:
			CanDataEncoderProcess(&m3508_2);break;
		case CAN_CHASSIS_MOTOR3_ID:
			CanDataEncoderProcess(&m3508_3);break;
		case CAN_CHASSIS_MOTOR4_ID:
			CanDataEncoderProcess(&m3508_4);break;
	}
}

/*
 * @brief CAN通信电机的反馈数据具体解析函数
 * @param 电机数据结构体
 * @retval None
 */
void CanDataEncoderProcess(struct CAN_Motor *motor)
{
	motor->last_fdbPosition = motor->fdbPosition;
	motor->fdbPosition = CanReceiveData[0]<<8|CanReceiveData[1];
	motor->fdbSpeed = CanReceiveData[2]<<8|CanReceiveData[3];

  /* 电机位置数据过零处理,避免出现位置突变的情况 */
	if(motor->fdbPosition - motor->last_fdbPosition > 4096)
	{
		motor->round --;
	}
	else if(motor -> fdbPosition - motor->last_fdbPosition < -4096)
	{
		motor->round ++;
	}
	motor->real_position = motor->fdbPosition + motor->round * 8192;

  /* 将电机速度反馈值由无符号整型转变为有符号整型 */
	if(motor->fdbSpeed > 32768)
	{
		motor->fdbSpeed -= 65536;
	}
}

上面的代码也很好懂。首先是CAN接收中断需要的储存数据的变量,这一点和串口通信是一样的,根据说明书知道每一次反馈是8字节的长度。

然后初始化CAN过滤器,这一步在软件里无法配置,所以只有手动添加上述过滤器函数,并在main函数中调用,这样才能进行CAN的接收中断函数。

在中断函数中,调用HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &RxHeader, CanReceiveData)函数接收数据,然后调用CanDataReceive(RxHeader.StdId);进行数据解析。在该函数中最终调用了void CanDataEncoderProcess(struct CAN_Motor *motor)函数,完成了电机反馈数据的解析。

关于电机的解析函数,可以参考C620的通信协议(前文已给出),我这里只解析了电机的机械角度和转子转速,没有解析电机的转矩电流和电机温度,这两个的解析大家有兴趣的可以去试试。其中电机转速是由方向的,也就是有正负,但是上述处理过程默认没有正负,于是-1会处理成65535,所以最后手动处理了一下。

角度过零处理

拿到电机的机械角度还要进行处理,因为存在从8191到00到8191的突变,这是要极力避免的(虽然底盘控制还用不到这个数据,但这里还是说一下)。电机的反馈频率为1KHz,通过实验,在1ms的时间内,电机的机械角度改变值最多也就一两千,所以一旦出现超过4096 (这个数据是我测试出来的,数据太小或太大都会出问题,具体原因大家可以自己去研究,推荐数值范围在3000~5000之间取值),那么肯定是过了零点(也就是那个突变位置),于是我把转子的圈数修正,并结合圈数和机械角度算出当前的真正机械角度,该数值连续变化,范围为-∞ ~ +∞(不考虑数据类型的上限的话)。过零处理的代码再单独拿出来看一下:

/* 电机位置数据过零处理,避免出现位置突变的情况 */
if(motor->fdbPosition - motor->last_fdbPosition > 4096)
{
	motor->round --;
}
else if(motor -> fdbPosition - motor->last_fdbPosition < -4096)
{
	motor->round ++;
}
motor->real_position = motor->fdbPosition + motor->round * 8192;

和遥控器的数据接收一样,把上述代码放进工程中,然后进入调试,查看m3508_x结构体,不出意外就会收到电机反馈的消息。

电机的速度环

既然接收到了电机的数据,我们就可以加入PID控制电机的速度了(什么?你问为什么要控制速度?再见!告辞!)。首先还是看一看怎么发送信息给电机。

给电机发送信息

/*
 * @brief ID为1~4的电机信号发送函数
 * @param ID为1~4的各个电机的电流数值
 * @retval None
 */
void CanTransmit_1234(CAN_HandleTypeDef *hcanx, int16_t cm1_iq, int16_t cm2_iq, int16_t cm3_iq, int16_t cm4_iq)
{
	CAN_TxHeaderTypeDef TxMessage;

	TxMessage.DLC=0x08;
	TxMessage.StdId=0x200;
	TxMessage.IDE=CAN_ID_STD;
	TxMessage.RTR=CAN_RTR_DATA;
	uint8_t TxData[8];

	TxData[0] = (uint8_t)(cm1_iq >> 8);
	TxData[1] = (uint8_t)cm1_iq;
	TxData[2] = (uint8_t)(cm2_iq >> 8);
	TxData[3] = (uint8_t)cm2_iq;
	TxData[4] = (uint8_t)(cm3_iq >> 8);
	TxData[5] = (uint8_t)cm3_iq;
	TxData[6] = (uint8_t)(cm4_iq >> 8);
	TxData[7] = (uint8_t)cm4_iq;

	if(HAL_CAN_AddTxMessage(hcanx,&TxMessage,TxData,(uint32_t*)CAN_TX_MAILBOX0)!=HAL_OK)
	{
		 Error_Handler();       //如果CAN信息发送失败则进入死循环
	}
}

把上述代码和C620的通信协议结合起来看。我们只需要给电调发送一个转矩电流的数字就行了,在上述代码中,我把16位的转矩电流拆分成了2个8位的数据,其实就是数据接收的逆过程。然后调用HAL_CAN_AddTxMessage(hcanx,&TxMessage,TxData,(uint32_t*)CAN_TX_MAILBOX0)函数将数据发送出去。

这里强调一下,我的代码里面使用的是CAN的RX0接收,发送使用的CAN_TX_MAILBOX0,也就是邮箱0,与这个配置不同的同学谨慎复制代码。

好,至此,我们电机速度环需要的反馈值fdb和输出值output都已经搞定了,现在来看看电机速度环的PID计算。

速度环

其实就是一个很普通很普通的PID公式,而且我这里只使用了P系数,I和D均为0。

/*
 * @brief PID计算函数。本PID增量式PID,未设置死区
 * @param None
 * @retval None
 */
void PID_Calc(struct PID_t *pid)
{
	pid->error[0] = pid->error[1];
	pid->error[1] = pid->ref - pid->fdb;
	pid->error_sum += pid->error[1];

	/* 积分上限 */
	if(pid->error_sum > pid->error_max) pid->error_sum = pid->error_max;
	if(pid->error_sum < -pid->error_max) pid->error_sum = -pid->error_max;

	pid->output = pid->KP*pid->error[1] + pid->KI*pid->error_sum+pid->KD*(pid->error[1]-pid->error[0]);

	/* 输出上限 */
	if(pid->output > pid->outputMax) pid->output = pid->outputMax;
	if(pid->output < -pid->outputMax) pid->output = -pid->outputMax;
}

要给每一个电机都指定一个PID结构体。

struct PID_t
{
	float KP;
	float KI;
	float KD;
	int error[2];
	int error_sum;
	int error_max;
	int fdb;
	int ref;
	int output;
	int outputMax;
};

/* 修改前面的电机数据结构体,在当中加入一个PID结构体 */
struct CAN_Motor
{
	int fdbPosition;        //电机的编码器反馈值
	int last_fdbPosition;   //电机上次的编码器反馈值
	int bias_position;      //机器人初始状态电机位置环设定值
	int fdbSpeed;           //电机反馈的转速/rpm
	int round;              //电机转过的圈数
	int real_position;      //过零处理后的电机转子位置
	struct PID_t speed_pid;     //电机速度环PID
};

在这里先停一停,PID计算怎么能没有电机的速度环设定值ref呢,下面就揭开底盘驱动 的核心代码。

麦克纳姆轮解析

首先把遥控器用上,用遥控器控制底盘的运动。

struct Chassis_t
{
	int FBSpeed;
	int LRSpeed;
	int RotateSpeed;
};

struct Chassis_t chassis;	//存储机器人底盘的速度

chassis.FBSpeed = (remote.ch1 - CH0_BIAS);
chassis.LRSpeed = (remote.ch0 - CH1_BIAS);
chassis.RotateSpeed = (remote.ch2 - CH2_BIAS);

m3508_1.speed_pid.ref = chassis.FBSpeed + chassis.LRSpeed + chassis.RotateSpeed;
m3508_2.speed_pid.ref = -chassis.FBSpeed + chassis.LRSpeed + chassis.RotateSpeed;
m3508_3.speed_pid.ref = -chassis.FBSpeed - chassis.LRSpeed + chassis.RotateSpeed;
m3508_4.speed_pid.ref = chassis.FBSpeed - chassis.LRSpeed + chassis.RotateSpeed;

我首先定义了一个用于储存底盘电机前后、左右和旋转的数据,然后将遥控器的摇杆数据与底盘数据挂钩。上面代码中的CHx_BIAS是对应摇杆的中间值,默认是1024 (为什么?看说明书啊!傻×!)

接下来四句话就是麦克纳姆轮底盘电机速度解析的全部了,可以结合前面的推荐的那篇博客学习理解。注意这里的算式中的正负号是与底盘电机的ID号分布密切相关的,这里的表达式只适用于我这种ID号分布。

PID计算

到这一步就不需要我说啥了,万事俱备,只欠东风。算!

m3508_1.speed_pid.fdb = m3508_1.fdbSpeed*0.136f;    //更新电机速度反馈值
m3508_2.speed_pid.fdb = m3508_2.fdbSpeed*0.136f;
m3508_3.speed_pid.fdb = m3508_3.fdbSpeed*0.136f;
m3508_4.speed_pid.fdb = m3508_4.fdbSpeed*0.136f;

PID_Calc(&m3508_1.speed_pid);         //进行PID速度环计算
PID_Calc(&m3508_2.speed_pid);
PID_Calc(&m3508_3.speed_pid);
PID_Calc(&m3508_4.speed_pid);

上面的代码有一个很神奇的系数0.136f诶,这个数怎么算出来的大家自己去摸索吧,其实去掉它也影响不大。

发送PID结果

然后是最后一步,把PID的output发送出去。

CanTransmit_1234(&hcan1, m3508_1.speed_pid.output, m3508_2.speed_pid.output, m3508_3.speed_pid.output, m3508_4.speed_pid.output);

总结

坦白说,上面的代码原封不动抄进工程里面是不行的,因为这里面还涉及到一些函数执行的方式,比如电机的PID计算和数据发送是要高频进行的(但是频率不能高于1KHz),遥控器改变电机PID的速度设定值这个函数也是要循环执行的,这个我不细说,留给大家自己去探索。

最后说一句,底盘驱动真的很简单很基础啊!

步进电机驱动总结

最近接了一个小项目,其中一个内容就是驱动步进电机。我就地取材选择了非常普遍的J-4218HB2401两相步进电机,搭配TB6600驱动器进行控制。

其实也没啥好总结的,都加上驱动器了,那自然是非常方便,这里就简单记录一下。

步进电机因为是两相的,一共有四根线引出来,分别是A+,A-,B+,B-,驱动器外观和接口如下图所示:
image

驱动器上的接线如下:

接口名 接线
PUL+ 脉冲信号输出负
PUL- 脉冲信号输入负
DIR+ 电机正、反转控制正
DIR- 电机正、反转控制负
EN+ 电机脱机控制正
EN- 电机脱机控制负
A+ 连接电机绕组A+相
A- 连接电机绕组A-相
B+ 连接电机绕组B+相
B- 连接电机绕组B-相
VCC 电源正极
GND 电源负极

DC 9-40V

接线有两种方法,一种是共阳极接法:
image

一种是共阴极接法:
image

我采用的是共阴极接法。

单片机我使用的是STM32F103C8T6,就简单的先来个1kHz脉冲,但是接上去,通上电,电机是自锁了,但是在脉冲输入下不转,一点反应都没有,上网一查在知道原来脉冲也要求5V高电平,而改进版的TB6600驱动器就不存在这个问题。

为了验证这个想法,我通上电后手动将PUL+碰上5V电源,果真碰一下转一下,玩的不亦乐乎,是时候展示真正的手速了!

那么,现在就下单买一个光耦继电器 😸

博客搭建过程

最近正值期末考试,今天刚考完一门,昨晚上实在是不想复习了,正想着怎么办的时候,突然想把复习的考点放在自己的博客上,正好我半年前搭建了一个博客,打开一看发现只有最开始搭建的时候的一篇博客,感觉很失败。

之前的这篇博客采用的是Hexo+NexT框架搭建的,但是我的电脑几经改装,系统也重装了几次,node.js早就没了,想要继续维护这个博客有些麻烦,索性全删了,从头手写一个博客。

首先明确目标,我对博客要求不高,能发文章就行了,所以博客的功能就两点:发文章、读文章。当然,最基本的文章分类是必须的。

然后开始找模板,因为我个人的UI设计能力实在堪忧,只有借鉴别人的设计。我仍然选择在NexT主题中寻找,很快我找到了一个不错的博客样式。然后我就使用自卑鄙的手段,按下F12查找元素的样式,顺手把图也扒了下来,基本上还原了博客的样式。

渐变动画

博客里有内容渐变动画菜单动画,我使用了css+Jquery实现,就拿我的博客的头像来进行说明吧。

头像的部分css样式如下:

.head {
    display: inline-block;
    width: 130px;
    height: 130px;
    border-radius: 50%;
    padding: 3px;
    background: #fff;
    box-shadow: 0 0 5px #95a5a6;
    position: relative;
    top: -68px;
    opacity: 0;
    transform: translateY(-20px);
    transition: all 0.8s;
}

上面的样式中最重要的是最后三句话,首先把透明度设置为0隐藏起来,位置向上偏移了20px,然后设置元素的每一次样式变化都在0.8s内匀速完成。

然后我在js代码里面改变它的透明度和偏移量就行了。

$(function(){
	$('.head').css("opacity","1");
	$('.head').css("transform","translateY(0)");
});

以同样的方法,菜单栏的动画也可以轻松实现。

手机屏幕适配

在手机端屏幕适配这方面,我选择了bootstrap框架的一小部分,这方面的相关介绍可以参考bootstrap中文网,但是使用的不多,主要是两个地方使用。

一个是每个页面下面的文章部分,使用了container类,可以根据屏幕的分辨率大小调整文章内容的宽度。

另一个地方是非首页的顶部巨幕,使用了jumbotron类。

其实我并没有怎么依赖bootstrap。首页的初始画面会默认填充整个屏幕,这里我使用了css里面自带的一个非常有用的单位。比如我的头像下方的div的样式如下:

.author {
    background: #ecf0f1;
    text-align: center;
    height: 30vh;
}

我使用了vh单位。在css中,默认将屏幕的长等分为100份,每一份的长度为1vh,这里的30vh就是占30%高度。对应的宽度也有vw这个单位,使用方法一样。这里需要提示的是,vw将滚动条的尺寸也算了进去,所以相对来说没有那么好用。

动态打字效果

博客首页头像的下方有一个一直在打字的效果,这个是我之前从其他网站上抠下来的,只需要引入这个typed.js,然后使用下面的语句调用就行了。

$("#changerificwordspanid").typed({
	strings: ["good", "happy", "healthy", "tall"],
	typeSpeed: 100,
	startDelay: 10,
	showCursor: true,
	shuffle: true,
	loop:true
});

将上面的Jquery的选择器和strings里面的内容替换成自己的就行了。
##文章的发表
这里我选择了MarkDown,众所周知MarkDown使用起来是及其方便的,我采用的就是网页js代码获取.md文件的内容后将样式渲染出来。

我使用的是开源的editor.md,非常方便,GitHub地址点这里。在我的博客的菜单栏里就可以体验MarkDown。


博客的相关搭建技术就是这些,剩下的就是细节的优化了。

我的博客是挂在GitHub上面的,所以可以可以直接点击下面的GitHub按钮查看我的网站源码。

LaTeX学习

明天开始美赛就开始了,终于下决心走出摸鱼的状态。首先学学LaTeX好了。

LaTeX命令

latex命令以反斜线 \ 开头

latex命令是对大小写敏感的。字母形式的LaTeX命令忽略其后的所有空格。如果要认为引入空格,需要在命令后面加一对括号阻止其忽略空格。

Shall we call ourselves \Tex\ users

or \Tex{} users?

大多数的LaTeX命令是带一个或多个参数,每个参数用花括号包裹。有些命令带一个或多个可选参数,以方括号包裹。还有些命令在命令名称后可以带一个星号*

LaTeX引入了环境的用法,用以令一些效果在局部生效,或是生成特定的文档元素。

\begin{<environment name>}{<arguments>}
...
\end{<environment name>}

环境允许嵌套使用。

LaTeX源代码以一个\documentclass命令作为开头,它规定了文档使用的文档类,紧接着我们可以用\usepackage命令调用宏包。再接着,我们需要用``\begin{document}\end{document}`来标记正文内容的开始位置和结束位置,而将正文内容写入其中。

\documentclass{...}
/* 这里是导言区,除调用宏包外,一些对文档的全局设置命令也在这里使用 */
\usepackage{...}
\begin{document}

\end{document}

用命令行操作LaTeX

\documentclass{article}
\begin{document}
‘‘Hello world!’’ from \LaTeX.
\end{document}

保存为helloworld.tex,然后打开命令行,执行下列语句:

latex helloworld.tex //后缀可选,这句话会生成helloworld.dvi
dvipdfmx helloworld.dvi //把dvi文件转为pdf

pdflatex helloworld.tex
xelatex helloworld.tex

文档类

\document[<options>]{<class-name>}

class-name:articlereportbook等。

可选参数options为文档类指定选项,以全局地影响文档布局的参数,如字号、纸张大小,单双面等等。比如调用article文档类排版文章,指定纸张为A4大小,基本字号为11pt,双面排版:

\documentclass[11pt, twoside, a4paper]{article}

文件的组织方式

当编写较大规模的LaTeX源代码,如书籍、毕业论文等,你有理由将源代码分成若干个文件,比如很自然地每章写一个文件。LaTeX提供了命令\include{<filename>}用来在源代码里插入文件。

文件名必要时需要加上相对或绝对路径,文件名不带扩展名的时候默认为/tex

值得注意的是\include在读入之前会另起一页,有时候我们并不需要这样,而是用\input命令。

另外LaTeX提供了一个\includeonly来组织文件,用于导言区,指定只载入某些文件。

最后介绍一个使用的工具宏包syntonly。加载这个宏包后,在导言区使用\syntaxonly命令,可令LaTeX编译后不生成 DVI 或者 PDF 文档,只排查错误,编译速度会快不少。

\usepackage{syntonly}
\syntaxonly

用LaTeX排版文字

排版中文

xeCJK宏包

\documentclass{article}
\usepackage{xeCJK}
\setCJKmainfont{SimSun}
\begin{document}
中文LaTeX排版。
\end{document}

ctex 宏包和文档类是对 CJK 和 xeCJK 等宏包的进一步封装。 ctex 文档类包括 ctexart /ctexrep / ctexbook,是对 LATEX 的三个标准文档类的封装,对 LATEX 的排版样式做了许多调整,以切合中文排版风格。最新版本的 ctex 宏包/文档类甚至支持自动配置字体。比如上述例子可进一步简化为:

\documentclass{ctexart}
\begin{document}
中文LaTeX排版。
\end{document}

LaTeX中的字符

空格键、tab键被视为“空格”。连续的若干个空白字符视为一个空格。一行开头的空格忽略不计。

行末的回车视为一个空格;连续两个回车,也就是空行,会将文字分段。多个空行被视为一个空行。也可以在行末使用\par命令分段。

LaTeX使用%字符作为注释。

下列字符要以带饭斜杠线的形式输入:

# $ % & { } _ ^ ~ \

其中\只能用\textbackslash输入,因为\\被直接定义成了手动换行的命令。

LaTeX双引号用''''输入。

LaTeX中有三种长度的“横线”可用:连字号、短破折号和长破折号。连字号永凯组成复合词;短破折号将数字连接表示范围;长破折号最为破折号使用。

X-rated\\
pages 13--67\\
yes---or no?

LaTeX提供了命令\ldots来生成省略号。\ldots\dots是两个等效的命令。

波浪号

a\~{}z \qquad a$\sim$z

文字强调

\underline命令给文字添加下划线

An \underline{underline} text.

但是这个命令比较**,不同的单词可能生成高低各异的下划线,并且无法换行。ulem宏包解决了这一个问题,它提供的\uline命令能够轻松生成自动换行的下划线。

An example of \uline{some long and undeline words.}

\emph命令用来将文字变为斜体以示强调。如果在本身已经用该命令强调的文字内部嵌套使用\emph,内部则使用正常字体的文字。
#章节和目录

\chapter{<title>} 
\section{<title>}
\subsection{<title>} 
\subsubsection{<title>}
\paragraph{<title>} 
\subparagraph{<title>}

\part命令用以将整个文档分割为大的分块,但不影响\chapter\section等的编号:

在LaTeX中生成目录非常容易,只需要在合适的地方使用命令:

\tableodcontents

所有标准文档类都提供了一个\appendix命令就昂正文和附录分开,使用之后,最高一级章节改为使用拉丁字母编号,从A开始。

book文档类还提供了前言、正文、后记结构的划分命令:

  • \frontmatter 前言部分,页码为小写罗马字母格式;其后的 \chapter 不编号。
  • \mainmatter 正文部分,页码为阿拉伯数字格式,从 1 开始计数;其后的章节编号正常。
  • \backmatter 后记部分,页码格式不变,继续正常计数;其后的 \chapter 不编号。

以上三个命令还可以与\appendix命令结合,生成有前言、正文、附录、后记四部分的文档。

\documentclass[...]{book}
% 导言区,加载宏包和各项设置
\usepackage{...}
% 此处示意对参考文献和索引的设置
\usepackage{makeidx}
\makeindex
\bibliographystyle{...}
\begin{document}
\frontmatter
\maketitle % 标题页
\include{preface} % 前言章节 preface.tex
\tableofcontents
\mainmatter
\include{chapter1} % 第一章 chapter1.tex
\include{chapter2} % 第二章 chapter2.tex
...
\appendix
\include{appendixA} % 附录 A appendixA.tex
...
\backmatter
\include{prologue} % 后记 prologue.tex
\bibliography{...} % 利用 BibTeX 工具生成参考文献
\printindex % 利用 makeindex 工具生成索引
\end{document}

交叉引用

交叉引用是LaTeX强大的自动排版功能的体现之一。在能够被交叉引用的地方,如章节、公式、图标、定理等位置使用\label命令:

\label{<label-name>}

之后可以在别处使用\ref\pageref命令,分贝生成交叉引用的编号和页码:

A reference to this subsection \label{sec:this} looks like:‘‘see section~\ref{sec:this} on page~\pageref{sec:this}.’’

脚注

使用\footnote命令可以在页面底部生成一个脚注:

“天地玄黄,宇宙洪荒。日月盈昃,辰宿列张。” \footnote{出自《千字文》。 }

列表

LaTeX提供了基本的有序和无序列表环境enumerate和itemize,两者的用法都很类似,都用\item表明每个列表项。enumerate环境会自动对列表项编号。

\begin{enumerate}
  \item An item.
  \begin{enumerate}
    \item A nested item.
    \item[*] A starred item.
    \item Another item. \label{itref}
  \end{enumerate}
  \item Go back to upper level.
  \item Reference(\ref{itref}).
\end{enumerate}

\item可带一个可选参数,将有序列表的计数或者无序列表的符号替换成自定义的符号。

列表可以嵌套使用,最多嵌套四层。

对齐

center、flushleft、flushright环境分别用于生成居中、左对齐和右对齐的文本环境。

\begin{center} . . . \end{center}
\begin{flushleft} . . . \end{flushleft}
\begin{flushright} . . . \end{flushright}

代码环境

有时候我们需要将一段代码原样转义输出,这就要用到代码环境verbatim,带星号的版本将更进一步将空格显示成␣。

\begin{verbatim}
#include <iostream>
int main()
{
  std::cout << "Hello, world!"
            << std::endl;
  return 0;
}
\end{verbatim}

表格

有点复杂啊。**LaTeX

图片

LaTeX本身不支持插图功能,需要由graphicx宏包辅助支持。在调用了这个宏包后,就可以使用\includegraphics命令加载图片了:

\includegraphics[<options>]{<filename>}

\includegraphics命令的可选参数支持=形式复制,常用的参数如下:

参数 含义
width 将图片缩放到宽度为width
height 将图片缩放到高度为height
scale 将图片相对于原尺寸缩放scale倍
angle 令图片逆时针旋转angle度

排版数学公式

这方面的许多命令和环境依赖于amsmath宏包。数学公式有两种排版方式:其一是与文字混排,称为行内公式,其二是单独列为一行排列,称为行间公式

行内公式由一对$符号包裹。

行间公式由equation环境包裹。该环境为公式自动生成一个编号,这个编号可以生成交叉引用。amsmath的\eqref命令甚至为引用自动加上圆括号。

Add $a$ squared and $b$ squared
to get $c$ squared
\begin{equation}
a^2 + b^2 = c^2
\end{equation}
Einstein says
\begin{equation}
E = mc^2 \label{clever}
\end{equation}
This is a reference to
\eqref{clever}.

\equation*环境中可以取消公式的自动编号。

在数学模式中,输入的空格全部被忽略。数学符号的间隙默认完全由符号的性质决定。需要人为引入空隙时,使用\quad\qquad等命令。

如果想在数学公式中输入正体的文文本,可以使用\text\mathrm命令。

MATLAB串口通信GUI程序

最近上的材料测试方法课程有一个大作业,要求根据原理做一种材料分析仪器的演示装置。像这种大制作的东西,毫无疑问我承担了其中的编程部分。在“产品经理”的强烈要求下,我用MATLAB写了一个串口通信的程序,可以获取STM32发送的数据,并绘制出曲线。

MATLAB GUI

之前也没用过MATLAB GUI,这次是因为时间比较紧,技术比较菜,MATLAB代码量挺少的,就选择了MATLAB,之前一直用的Qt,本来也想尝试electron的,但是好像它不能访问USB设备。

所以现学了一波MATLAB GUI操作,实在是很简单,正所谓MATLAB一时爽,一直MATLAB一直爽。

首先在命令行输入'guide'新建一个空白GUI。

>> guide

image

然后简单添加,效果如下图所示:

image

其中下拉菜单我一直添加到了COM23口,可谓是丧心病狂。

实现逻辑

其实GUI开发很简单,全程写回调函数就行了,界面设计又是图形化拖动,简直没难度有木有。

我这里只有一个下拉菜单,两个按钮,所以有三个回调函数,再加上一个串口接收中断回调函数,一共四个函数。

下拉菜单回调函数

% --- Executes on selection change in ListMenu_COM_Port.
function ListMenu_COM_Port_Callback(hObject, eventdata, handles)
% hObject    handle to ListMenu_COM_Port (see GCBO)
% eventdata  reserved - to be defined in a future version of MATLAB
% handles    structure with handles and user data (see GUIDATA)
str = get(hObject, 'String');
val = get(hObject, 'Value');
% Set current data to the selected data set.
switch str{val};
    case 'COM1'
        fprintf('Select COM1\n');
        handles.device_port_current = serial('COM1');
    case 'COM2'
        fprintf('Select COM2\n');
        handles.device_port_current = serial('COM2');
    case 'COM3'
        fprintf('Select COM3\n');
        handles.device_port_current = serial('COM3');
    case 'COM4'
        fprintf('Select COM4\n');
        handles.device_port_current = serial('COM4');
    case 'COM5'
        fprintf('Select COM5\n');
        handles.device_port_current = serial('COM5');
    case 'COM6'
        fprintf('Select COM6\n');
        handles.device_port_current = serial('COM6');
    case 'COM7'
        fprintf('Select COM7\n');
        handles.device_port_current = serial('COM7');
    case 'COM8'
        fprintf('Select COM8\n');
        handles.device_port_current = serial('COM8');
    case 'COM9'
        fprintf('Select COM9\n');
        handles.device_port_current = serial('COM9');
    case 'COM10'
        fprintf('Select COM10\n');
        handles.device_port_current = serial('COM10');
    case 'COM11'
        fprintf('Select COM11\n');
        handles.device_port_current = serial('COM11');
    case 'COM12'
        fprintf('Select COM12\n');
        handles.device_port_current = serial('COM12');
    case 'COM13'
        fprintf('Select COM13\n');
        handles.device_port_current = serial('COM13');
    case 'COM14'
        fprintf('Select COM14\n');
        handles.device_port_current = serial('COM14');
    case 'COM15'
        fprintf('Select COM15\n');
        handles.device_port_current = serial('COM15');
    case 'COM16'
        fprintf('Select COM16\n');
        handles.device_port_current = serial('COM16');
    case 'COM17'
        fprintf('Select COM17\n');
        handles.device_port_current = serial('COM17');
    case 'COM18'
        fprintf('Select COM18\n');
        handles.device_port_current = serial('COM18');
    case 'COM19'
        fprintf('Select COM19\n');
        handles.device_port_current = serial('COM19');
    case 'COM20'
        fprintf('Select COM20\n');
        handles.device_port_current = serial('COM20');
    case 'COM21'
        fprintf('Select COM21\n');
        handles.device_port_current = serial('COM21');
    case 'COM22'
        fprintf('Select COM22\n');
        handles.device_port_current = serial('COM22');
    case 'COM23'
        fprintf('Select COM23\n');
        handles.device_port_current = serial('COM23');
    
end
guidata(hObject, handles); %保存配置

“打开串口”按钮回调函数

% --- Executes on button press in OpenCom.
function OpenCom_Callback(hObject, eventdata, handles)
% hObject    handle to OpenCom (see GCBO)
% eventdata  reserved - to be defined in a future version of MATLAB
% handles    structure with handles and user data (see GUIDATA)
set(handles.device_port_current, 'BaudRate',115200,'Parity','none','stopbits',1,'databits',8,'FlowControl','none'); %配置串口波特率,停止位,校验位,字节长度等
handles.device_port_current.BytesAvailableFcnCount = 1; %配置每接收到一个字节就产生一次中断
handles.device_port_current.BytesAvailableFcnMode = 'byte';
handles.device_port_current.BytesAvailableFcn = { @ReceiveBytesAvailableFcn, handles}; %配置接收回调函数

handles.output = hObject;
guidata(hObject, handles);

fopen(handles.device_port_current); %打开串口
set(handles.OpenCom, 'enable', 'off'); %打开串口后,“打开串口”按钮变灰
set(handles.ListMenu_COM_Port, 'enable', 'off');%打开串口后,禁止使用下拉菜单
set(handles.CloseCom, 'enable', 'on'); %打开串口后,使能“关闭串口”按钮
CurrentPortName = handles.device_port_current.name;
fprintf(CurrentPortName);
fprintf(' is open!\n');

“关闭串口”按钮回调函数

% --- Executes on button press in CloseCom.
function CloseCom_Callback(hObject, eventdata, handles)
% hObject    handle to CloseCom (see GCBO)
% eventdata  reserved - to be defined in a future version of MATLAB
% handles    structure with handles and user data (see GUIDATA)
CurrentPortName = handles.device_port_current.name;
fclose(handles.device_port_current); %关闭串口
set(handles.OpenCom, 'enable', 'on');
set(handles.ListMenu_COM_Port, 'enable', 'on');
set(handles.CloseCom, 'enable', 'off');
fprintf(CurrentPortName);
fprintf(' is closed!\n');

串口接收中断函数

%接收回调函数
function ReceiveBytesAvailableFcn(hObject, eventdata, handles)
ReceiveData = fread(handles.device_port_current, 1, 'int8');
persistent data_count;
persistent stm32_data;
if(isempty(data_count))
    data_count = 1;
end
if(isempty(stm32_data))
    stm32_data = 0;
end
stm32_data(data_count) = ReceiveData;
data_count = data_count + 1;
if(stm32_data(1) ~= 125)
    data_count = 1;
    return;
end
if(data_count < 5)
    return;
end
if(stm32_data(4) ~= 120)
    data_count = 1;
    return;
end
persistent h1;
persistent h2;
persistent count;
if(isempty(count))
    count = 0;
end
if(isempty(h1))
    h1 = animatedline('Color','r','LineWidth',2);
end
if(isempty(h2))
    h2 = animatedline('Color','b','LineWidth',2);
end
addpoints(h1,count,stm32_data(2));
addpoints(h2,count,stm32_data(3));
count = count + 1;
%set(handles.axes1,'XLim',[0 count],'YLim', [-1000 1000]);
drawnow limitrate;
data_count = 1;

这里是整个GUI程序最重要的地方,通信协议如下:

帧头 数据1 数据2 帧尾
125 data1 data2 120

接收到数据之后,使用animatedlineaddpoints来动态绘制函数。

初始化函数

初始化函数是MATLAB自动添加好的,我在其中添加了几句:

% --- Executes just before COM is made visible.
function COM_OpeningFcn(hObject, eventdata, handles, varargin)
% This function has no output args, see OutputFcn.
% hObject    handle to figure
% eventdata  reserved - to be defined in a future version of MATLAB
% handles    structure with handles and user data (see GUIDATA)
% varargin   command line arguments to COM (see VARARGIN)

% Choose default command line output for COM
handles.output = hObject;

% Update handles structure
guidata(hObject, handles);

%初始化按钮
set(handles.OpenCom, 'enable', 'on');
set(handles.ListMenu_COM_Port, 'enable', 'on');
set(handles.CloseCom, 'enable', 'off');
handles.device_port_current = serial('COM1');
% UIWAIT makes COM wait for user response (see UIRESUME)
% uiwait(handles.figure1);

最终效果

最终效果

MPU9250六轴算法

前段时间我研究了MPU9250的数据读取和九轴姿态解析算法,效果还不错,详情点这里

但是今天发现九轴算法得出来的角度有两个问题,一个是波动较大,一个是会出现下图所示的斜坡。

image

后来我发现,应该是陀螺仪积分出来的角度和磁力计加速度计坐标转换过来的角度不一致,导致被缓慢纠正,进一步发现pitch轴和roll轴没有这个问题,只有yaw轴有,所以是磁力计的问题。

磁力计这东西吧,问题还真挺多,到了一个新地方还要重新校准一下,而且特别容易受到干扰。我采集了一段数据后发现,加速度、角速度、磁场强度,就磁场数据波动最大,这也是角度波动大的罪魁祸首。

我尝试对磁力计进行低通滤波或者均值滤波,但效果都不是很好,最后角度数值稳定下来了,但是那个斜坡还是无法消除。

没办法,暂时抛下磁力计试试六轴算法,也就是说只使用MPU9250里面的MPU6050。

六轴算法

我参考了正点原子的飞控代码,在正式总结之前先说一声正点原子牛逼!

陀螺仪数据

六轴算法中最重要的就是陀螺仪数据的处理,因为陀螺仪数据是四元数更新的根本依据。加速度能纠正pitch轴和roll轴,所以yaw轴的稳定全靠陀螺仪数据的准确。

这里主要是采集一段陀螺仪静止的数据,找到三轴数据的静差。我采集了1024个数据,先计算他们的方差,如果方差大于阈值,则表明陀螺仪不是静止的,则不进行校准,当方差满足要求时,计算样本数据的平均值,当做陀螺仪的静差。

处理过后,才静止情况下三轴的陀螺仪数据小数点后两位都是0,效果还不错。

实现代码如下,函数较多:

#define SENSORS_NBR_OF_BIAS_SAMPLES		1024	/*计算方差的采样样本个数 */
#define GYRO_VARIANCE_BASE				4000	/* 陀螺仪零偏方差阈值 */

/*计算方差和平均值*/
static void sensorsCalculateVarianceAndMean(BiasObj* bias, struct Axisf* varOut, struct Axisf* meanOut)
{
	uint32_t i;
	int64_t sum[3] = {0};
	int64_t sumsq[3] = {0};

	for (i = 0; i < SENSORS_NBR_OF_BIAS_SAMPLES; i++)
	{
		sum[0] += bias->buffer[i].x;
		sum[1] += bias->buffer[i].y;
		sum[2] += bias->buffer[i].z;
		sumsq[0] += bias->buffer[i].x * bias->buffer[i].x;
		sumsq[1] += bias->buffer[i].y * bias->buffer[i].y;
		sumsq[2] += bias->buffer[i].z * bias->buffer[i].z;
	}

	varOut->x = (sumsq[0] - ((int64_t)sum[0] * sum[0]) / SENSORS_NBR_OF_BIAS_SAMPLES);
	varOut->y = (sumsq[1] - ((int64_t)sum[1] * sum[1]) / SENSORS_NBR_OF_BIAS_SAMPLES);
	varOut->z = (sumsq[2] - ((int64_t)sum[2] * sum[2]) / SENSORS_NBR_OF_BIAS_SAMPLES);

	meanOut->x = (float)sum[0] / SENSORS_NBR_OF_BIAS_SAMPLES;
	meanOut->y = (float)sum[1] / SENSORS_NBR_OF_BIAS_SAMPLES;
	meanOut->z = (float)sum[2] / SENSORS_NBR_OF_BIAS_SAMPLES;
}

/*传感器查找偏置值*/
static int sensorsFindBiasValue(BiasObj* bias)
{
	int foundbias = 0;

	if (bias->isBufferFilled)
	{
		
		struct Axisf mean;
		struct Axisf variance;
		sensorsCalculateVarianceAndMean(bias, &variance, &mean);

		if (variance.x < GYRO_VARIANCE_BASE && variance.y < GYRO_VARIANCE_BASE && variance.z < GYRO_VARIANCE_BASE)
		{
			bias->bias.x = mean.x;
			bias->bias.y = mean.y;
			bias->bias.z = mean.z;
			foundbias = 1;
			bias->isBiasValueFound= 1;
		}else
			bias->isBufferFilled=0;
	}
	return foundbias;
}

/**
 * 计算陀螺方差
 */
int processGyroBias(int16_t gx, int16_t gy, int16_t gz, struct Axisf *gyroBiasOut)
{
	static int count = 0;
	gyroBiasRunning.buffer[count].x = gx;
	gyroBiasRunning.buffer[count].y = gy;
	gyroBiasRunning.buffer[count].z = gz;
	count++;
	if(count == 1024)
	{
		count = 0;
		gyroBiasRunning.isBufferFilled = 1;
	}

	if (!gyroBiasRunning.isBiasValueFound)
	{
		sensorsFindBiasValue(&gyroBiasRunning);
	}

	gyroBiasOut->x = gyroBiasRunning.bias.x;
	gyroBiasOut->y = gyroBiasRunning.bias.y;
	gyroBiasOut->z = gyroBiasRunning.bias.z;

	return gyroBiasRunning.isBiasValueFound;
}

void imuDataHandle(void)
{
	//....
	gyroBiasFound = processGyroBias(gyrox, gyroy, gyroz, &gyroBias);

	fgyrox = -(float)(gyrox-gyroBias.x)/gyro_sensitivity;
	fgyroy = (float)(gyroy-gyroBias.y)/gyro_sensitivity;
	fgyroz = (float)(gyroz-gyroBias.z)/gyro_sensitivity;
	//...
}

这里的方差计算与正常的方差计算公式有些不同,算是近似了一下吧,比较方便写程序一点。

加速度数据

加速度数据处理起来就简单很多,主要是计算一个缩放因子,直接贴上代码吧:

/**
 * 根据样本计算重力加速度缩放因子
 */
int processAccScale(int16_t ax, int16_t ay, int16_t az)
{
	static int accBiasFound = 0;
	static uint32_t accScaleSumCount = 0;

	if (!accBiasFound)
	{
		accScaleSum += sqrtf(powf((float)ax/8192, 2) + powf((float)ay/8192, 2) + powf((float)az/8192, 2));
		accScaleSumCount++;

		if (accScaleSumCount == SENSORS_ACC_SCALE_SAMPLES)
		{
			accScale = accScaleSum / SENSORS_ACC_SCALE_SAMPLES;
			accBiasFound = 1;
		}
	}

	return accBiasFound;
}

void imuDataHandle(void)
{
	//....
	if (gyroBiasFound)
	{
		processAccScale(accx, accy, accz);	/*计算accScale*/
	}

	faccx = -(float)(accx)/acc_sensitivity/accScale;
	faccy = (float)(accy)/acc_sensitivity/accScale;
	faccz = (float)(accz)/acc_sensitivity/accScale;
	//...
}

数据融合

融合算法和九轴的一样,只是少了磁力计一项,代码如下:

void imuUpdate(struct Axisf acc, struct Axisf gyro)
{
	float q0q0 = q0 * q0;
	float q1q1 = q1 * q1;
	float q2q2 = q2 * q2;
	float q3q3 = q3 * q3;

	float q0q1 = q0 * q1;
	float q0q2 = q0 * q2;
	float q0q3 = q0 * q3;
	float q1q2 = q1 * q2;
	float q1q3 = q1 * q3;
	float q2q3 = q2 * q3;
	
	float normalise;
	float ex, ey, ez;
	float halfT;
	float vx, vy, vz;
	
	now_update = HAL_GetTick(); //单位ms
	halfT = ((float)(now_update - last_update) / 2000.0f);
	last_update = now_update;
	
	gyro.x *= DEG2RAD;	/*度转弧度*/
	gyro.y *= DEG2RAD;
	gyro.z *= DEG2RAD;
	
	/* 对加速度计数据进行归一化处理 */
	if(acc.x != 0 || acc.y != 0 || acc.z != 0)
	{
		normalise = sqrt(acc.x * acc.x + acc.y * acc.y + acc.z * acc.z);
		acc.x /= normalise;
		acc.y /= normalise;
		acc.z /= normalise;
	}
	
	/* 计算加速度计投影到物体坐标上的各个分量 */
	vx = 2.0f*(q1q3 - q0q2);
	vy = 2.0f*(q0q1 + q2q3);
	vz = q0q0 - q1q1 - q2q2 + q3q3;

	/* 叉积误差累计,用以修正陀螺仪数据 */
	ex = (acc.y*vz - acc.z*vy);
	ey = (acc.z*vx - acc.x*vz);
	ez = (acc.x*vy - acc.y*vx);
	
	/* 互补滤波 PI */
	exInt += ex * Ki * halfT;
	eyInt += ey * Ki * halfT;	
	ezInt += ez * Ki * halfT;
	gyro.x += Kp*ex + exInt;
	gyro.y += Kp*ey + eyInt;
	gyro.z += Kp*ez + ezInt;
	
	/* 使用一阶龙格库塔更新四元数 */
	q0 += (-q1 * gyro.x - q2 * gyro.y - q3 * gyro.z) * halfT;
	q1 += ( q0 * gyro.x + q2 * gyro.z - q3 * gyro.y) * halfT;
	q2 += ( q0 * gyro.y - q1 * gyro.z + q3 * gyro.x) * halfT;
	q3 += ( q0 * gyro.z + q1 * gyro.y - q2 * gyro.x) * halfT;
	
	/* 对四元数进行归一化处理 */
	normalise = sqrt(q0 * q0 + q1 * q1 + q2 * q2 + q3 * q3);
	q0 /= normalise;
	q1 /= normalise;
	q2 /= normalise;
	q3 /= normalise;
	
	/* 由四元数求解欧拉角 */
	mpu9250.attitude.x = -asinf(-2*q1*q3 + 2*q0*q2) * RAD2DEG;	//pitch
	mpu9250.attitude.y = atan2f(2*q2*q3 + 2*q0*q1, -2*q1*q1 - 2*q2*q2 + 1) * RAD2DEG;	//roll
	mpu9250.attitude.z = atan2f(2*q1*q2 + 2*q0*q3, -2*q2*q2 - 2*q3*q3 + 1) * RAD2DEG;	//yaw
}

最终效果

image

后话

后来我兴致冲冲地把六轴算法移植到步兵车上,结果云台还是响应速度慢,摆尾动作云台稳不住。

**的为什么

WS2811驱动

今年比赛的能量机关大变样,成了一个大风车,我们决定自己也做一个,官方的灯条是SK6812灯珠,每米144个灯珠,但是真的贵,最后选择了每米60珠的WS2811灯珠。

这一类的灯珠自带了IC,只需要一根信号线就能控制灯条上的每一个灯的颜色,且灯条长度理论上可以无限长。

这类灯条的控制方式都差不多,只是逻辑0和逻辑1的表现方式不同而已。我手里这款灯条的驱动方式如下:

image

image

image

image

这里要吐槽一下,就是逻辑0和逻辑1的表示方法这里,我在百度上看到了三种版本,除了上面这种正确的外,另外两种如下:

image

image

我真的是佛了,导致逻辑0和逻辑1的发送成了我驱动灯条最大的障碍。如果时序不对,那么整个灯条的颜色都是乱的。

后来我使出了绝招,用示波器看对应的灯条遥控器的输出信号,结果如下:

  • 逻辑0
    image

  • 逻辑1
    image

从图中可以大致读出,T0H大约300nsT0L大约900nsT1H大约600nsT1L大约600ns

另外还要说一下,像这种一根信号线输出控制的一般逻辑电平都要求5V及以上,而STM32只能输出3.3V,所以这里使用arduino,但是arduino有一个致命的弱点,就是封装得太死,导致执行效率不高。

arduino快速操作IO口

arduino操作IO口需要两步,第一步设置IO口为输出模式,第二步控制IO口电平高低,代码如下:

setup()
{
  pinMode(8, OUTPUT);
}

void loop()
{
  digitalWrite(8,0);  //输出低电平
  digitalWrite(8,1);  //输出高电平
}

但是这种方式的话,哪怕我在loop()中一直翻转IO口,不带任何延时,IO口翻转一次需要大约8.5us,而驱动所需要的时间最短只有300ns,远远无法满足要求。

后来我找到了这篇文章,通过操作“寄存器”的方式进行翻转IO口,实测可以达到8MHz左右,而驱动信号大约在3MHz左右,满足需求。

image

上图的前半部分就是使用函数翻转IO的情况,后面两个尖峰是操作寄存器的结果,可以看出使用函数是真的慢。

后面我还发现arduino的一个无语的地方,loop()函数的相邻两次执行是有较长的间隔时间的,具体体现在下图:

image

上图对应的代码如下:

loop()
{
  PORTB = B00000001;
  PORTB = B00000000;
  PORTB = B00000001;
  PORTB = B00000000;
}

可见两次loop()执行之间的间隔时间大约等于IO两次翻转的时间。

arduino纳秒级别延时

快速操作IO口的问题解决了,延时又是一个大问题,arduino里面的延时函数只有delay()一个毫秒级延时,百度之后发现了这篇文章,里面提到了使用nop指令进行延时,每次延时62.5ns,满足使用要求。

一切准备就绪,直接上代码。

逻辑0

逻辑0的代码如下:

  PORTB = B00000001;
  NOP;
  NOP;
  NOP;
  PORTB = B00000000;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;

逻辑1

逻辑1的代码如下:

  PORTB = B00000001;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  PORTB = B00000000;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;

灯条驱动源码

整个灯条的驱动代码如下:

#define NOP do { __asm__ __volatile__ ("nop"); } while (0)

int count = 0;

int arrow[7][8] = 
{
  {0,0,0,1,0,0,0,1},
  {1,0,0,0,1,0,0,0},
  {0,0,1,0,0,0,1,0},
  {0,1,0,0,0,1,0,0},
  {0,0,1,0,0,0,1,0},
  {1,0,0,0,1,0,0,0},
  {0,0,0,1,0,0,0,1}
};

void setup()
{
  DDRB = B00000001;
}

void loop()
{
  for(int i=0;i<7;i++)
  {
    if(i%2 == 0)
    {
      int temp = arrow[i][0];
      for(int j=0;j<7;j++)
      {
        arrow[i][j] = arrow[i][j+1];
      }
      arrow[i][7] = temp;
    }
    else
    {
      int temp = arrow[i][7];
      for(int j=7;j>0;j--)
      {
        arrow[i][j] = arrow[i][j-1];
      }
      arrow[i][0] = temp;
    }
  }
  for(int i=0;i<7;i++)
  {
    for(int j=0;j<8;j++)
    {
      if(arrow[i][j] == 0)
      {
        WS2811_None();
      }
      else
      {
        WS2811_Red();
      }
    }
  }
  /*for(int i=0;i<56;i++)
  {
    WS2811_Blue();
  }*/
  PORTB = B00000000;
  delay(100);
}

void WS2811_Red()
{
  for(int i=0;i<8;i++)
  {
    PORTB = B00000001;
    //NOP;
  //NOP;
  //NOP;
    PORTB = B00000000;
    NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;NOP;NOP;NOP;NOP;NOP;
  }
  for(int i=0;i<8;i++)
  {
    PORTB = B00000001;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;NOP;
  PORTB = B00000000;
  NOP;
  NOP;
  NOP;
  NOP;NOP;NOP;NOP;
  }
  for(int i=0;i<8;i++)
  {
    PORTB = B00000001;
    //NOP;
  //NOP;
  //NOP;
    PORTB = B00000000;
    NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;NOP;NOP;NOP;NOP;NOP;
  }
}

void WS2811_Green()
{
  for(int i=0;i<8;i++)
  {
    PORTB = B00000001;
    //NOP;
  //NOP;
  //NOP;
    PORTB = B00000000;
    NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;NOP;NOP;NOP;NOP;NOP;
  }
  for(int i=0;i<8;i++)
  {
    PORTB = B00000001;
    //NOP;
  //NOP;
  //NOP;
    PORTB = B00000000;
    NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;NOP;NOP;NOP;NOP;NOP;
  }
  for(int i=0;i<8;i++)
  {
    PORTB = B00000001;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;NOP;
  PORTB = B00000000;
  NOP;
  NOP;
  NOP;
  NOP;NOP;NOP;NOP;
  }
}

void WS2811_Blue()
{
  for(int i=0;i<8;i++)
  {
    PORTB = B00000001;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;NOP;
  PORTB = B00000000;
  NOP;
  NOP;
  NOP;
  NOP;NOP;NOP;NOP;
  }
  for(int i=0;i<8;i++)
  {
    PORTB = B00000001;
    //NOP;
  //NOP;
  //NOP;
    PORTB = B00000000;
    NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;NOP;NOP;NOP;NOP;NOP;
  }
  for(int i=0;i<8;i++)
  {
    PORTB = B00000001;
    //NOP;
  //NOP;
  //NOP;
    PORTB = B00000000;
    NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;NOP;NOP;NOP;NOP;NOP;
  }
}

void WS2811_None()
{
  for(int i=0;i<24;i++)
  {
    PORTB = B00000001;
    //NOP;
    //NOP;
    PORTB = B00000000;
    NOP;
  NOP;
  NOP;
  NOP;
  NOP;
  NOP;NOP;NOP;NOP;NOP;
  }
}

我把灯条组成了一个点阵,具体的实现效果如下(与贴出来的代码不符,贴出来的代码效果有一些小改动):

WS2811

SPI驱动

但是使用延时的方案毕竟只是一种时序近似,随着灯条长度的加长和信号发送时间的推移,总会出现逻辑0和1的误识别的情况,显示出来就是灯条上某几个LED灯的颜色反常。这里介绍一种更加稳定的方案,但可以我因为STM32不能输出5V而没有用上。

WS2811的工作频率为800KHz,如果将SPI的频率设置为6.4MHz,正好是灯条IC芯片的8倍,那么每一个字节(8 bits)正好对应一个逻辑位,也就是11110000代表逻辑1,11000000代表逻辑0,操作起来也非常方便。

我用示波器实测高低电平时间非常稳定,几乎和WS2811发送给下一个芯片的标准电平信号一模一样。

可惜的是,将3.3V升到5V这一步没有成功,三极管、MOS管、光耦隔离都尝试过了,5V信号的上升时间非常长,完全跟不上信号的变化,导致几乎输不出信号,没办法才转战arduino的。

不过最后好歹是成功驱动灯条了。

DS18B20温度传感器数据读取

DS18B20温度传感器提供9-Bits到12-Bits的高精度温度数据,我手上的封装类型如下:

image

一开始盲目自信,觉得这就是一个三极管,两端电压随温度改变,直接ADC读取电压值就行了,结果半天没反应,网上一搜数据手册才知道,这温度传感器中大有文章。

DS18B20简介

单线接口

DS18B20有其独特的单线接口方式,常见的通信协议都至少是两根线,而DS18B20利用它自己的一套通信协议实现了单线半双工通信。

通过这唯一的信号线可以实现数据读取,配置寄存器等操作,温度数据直接以数字信号形式返回来,比ADC高级多了。

唯一ROM

每一个DS18B20在出厂的时候都自带了唯一的光刻64位ROM,前8位是传感器的ID,这样每一个DS18B20的ID都不一样,可以实现总线的功能,理论上总线上的设备可以无限多,而这些都是在一根信号线上完成的。

读取DS18B20数据

DS18B20对逻辑0和逻辑1有自己的定义,且收发不同,具体见下图:

image

DS18B20的可操作寄存器如下表所示:

image

DS18B20的每一次读/写操作都必须按照下面的步骤进行:

TRANSACTION SEQUENCE

The transaction sequence for accessing the DS18B20 is as follows:

  • Step 1. Initialization
  • Step 2. ROM Command (followed by any required data exchange)
  • Step 3. DS18B20 Function Command (followed by any required data exchange)
    It is very important to follow this sequence every time the DS18B20 is accessed, as the DS18B20 will not respond if any steps in the sequence are missing or out of order. Exceptions to this rule are the Search ROM [F0h] and Alarm Search [ECh] commands. After issuing either of these ROM commands, the master must return to Step 1 in the sequence.

实现代码

这里涉及到了微秒延时,实现方法可参考HAL库实现us级延时

/**
  * 函数功能: 使DS18B20-DATA引脚变为上拉输入模式
  * 输入参数: 无
  * 返 回 值: 无
  * 说    明:无
  */
static void DS18B20_Mode_IPU(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
  GPIO_InitTypeDef GPIO_InitStruct;
  
  /* 串口外设功能GPIO配置 */
  GPIO_InitStruct.Pin   = GPIO_Pin;
  GPIO_InitStruct.Mode  = GPIO_MODE_INPUT;
  GPIO_InitStruct.Pull  = GPIO_PULLUP;
  HAL_GPIO_Init(GPIOx, &GPIO_InitStruct);
        
}

/**
  * 函数功能: 使DS18B20-DATA引脚变为推挽输出模式
  * 输入参数: 无
  * 返 回 值: 无
  * 说    明:无
  */
static void DS18B20_Mode_Out_PP(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
  GPIO_InitTypeDef GPIO_InitStruct;
  
  /* 串口外设功能GPIO配置 */
  GPIO_InitStruct.Pin = GPIO_Pin;
  GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
  GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
  HAL_GPIO_Init(GPIOx, &GPIO_InitStruct);          
}

/**
  * 函数功能: 主机给从机发送复位脉冲
  * 输入参数: 无
  * 返 回 值: 无
  * 说    明:无
  */
static void DS18B20_Rst(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
	/* 主机设置为推挽输出 */
	DS18B20_Mode_Out_PP(GPIOx, GPIO_Pin);
	
	HAL_GPIO_WritePin(GPIOx, GPIO_Pin, GPIO_PIN_RESET);

	/* 主机至少产生480us的低电平复位信号 */
	DS18B20_Delay(750);
	
	/* 主机在产生复位信号后,需将总线拉高 */
	HAL_GPIO_WritePin(GPIOx, GPIO_Pin, GPIO_PIN_SET);
	
	/*从机接收到主机的复位信号后,会在15~60us后给主机发一个存在脉冲*/
	DS18B20_Delay(15);
}

/**
  * 函数功能: 检测从机给主机返回的存在脉冲
  * 输入参数: 无
  * 返 回 值: 0:成功,1:失败
  * 说    明:无
  */
static uint8_t DS18B20_Presence(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
	uint8_t pulse_time = 0;
	
	/* 主机设置为上拉输入 */
	DS18B20_Mode_IPU(GPIOx, GPIO_Pin);
	
	/* 等待存在脉冲的到来,存在脉冲为一个60~240us的低电平信号 
	 * 如果存在脉冲没有来则做超时处理,从机接收到主机的复位信号后,会在15~60us后给主机发一个存在脉冲
	 */
	while( HAL_GPIO_ReadPin(GPIOx, GPIO_Pin) && pulse_time<100 )
	{
		pulse_time++;
		DS18B20_Delay(1);
	}        
	/* 经过100us后,存在脉冲都还没有到来*/
	if( pulse_time >=100 )
		return 1;
	else
		pulse_time = 0;
	
	/* 存在脉冲到来,且存在的时间不能超过240us */
	while( !HAL_GPIO_ReadPin(GPIOx, GPIO_Pin) && pulse_time<240 )
	{
		pulse_time++;
		DS18B20_Delay(1);
	}        
	if( pulse_time >=240 )
		return 1;
	else
		return 0;
}

/**
  * 函数功能: DS18B20 初始化函数
  * 输入参数: 无
  * 返 回 值: 无
  * 说    明:无
  */
uint8_t DS18B20_Init(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
  __HAL_RCC_GPIOA_CLK_ENABLE();
	DS18B20_Mode_Out_PP(GPIOx, GPIO_Pin);
	HAL_GPIO_WritePin(GPIOx, GPIO_Pin, GPIO_PIN_SET);
  
	DS18B20_Rst(GPIOx, GPIO_Pin);
  
  return DS18B20_Presence (GPIOx, GPIO_Pin);
}

/**
  * 函数功能: 从DS18B20读取一个bit
  * 输入参数: 无
  * 返 回 值: 读取到的数据
  * 说    明:无
  */
static uint8_t DS18B20_ReadBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
	uint8_t dat;
	
	/* 读0和读1的时间至少要大于60us */        
	DS18B20_Mode_Out_PP(GPIOx, GPIO_Pin);
	/* 读时间的起始:必须由主机产生 >1us <15us 的低电平信号 */
	HAL_GPIO_WritePin(GPIOx, GPIO_Pin, GPIO_PIN_RESET);
	DS18B20_Delay(10);
	
	/* 设置成输入,释放总线,由外部上拉电阻将总线拉高 */
	DS18B20_Mode_IPU(GPIOx, GPIO_Pin);
	
	if( HAL_GPIO_ReadPin(GPIOx, GPIO_Pin) == SET )
		dat = 1;
	else
		dat = 0;
	
	/* 这个延时参数请参考时序图 */
	DS18B20_Delay(45);
	
	return dat;
}

/**
  * 函数功能: 从DS18B20读一个字节,低位先行
  * 输入参数: 无
  * 返 回 值: 读到的数据
  * 说    明:无
  */
static uint8_t DS18B20_ReadByte(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
	uint8_t i, j, dat = 0;        
	
	for(i=0; i<8; i++) 
	{
		j = DS18B20_ReadBit(GPIOx, GPIO_Pin);                
		dat = (dat) | (j<<i);
	}
	
	return dat;
}

/**
  * 函数功能: 写一个字节到DS18B20,低位先行
  * 输入参数: dat:待写入数据
  * 返 回 值: 无
  * 说    明:无
  */
static void DS18B20_WriteByte(uint8_t dat, GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
	uint8_t i, testb;
	DS18B20_Mode_Out_PP(GPIOx, GPIO_Pin);
	
	for( i=0; i<8; i++ )
	{
		testb = dat&0x01;
		dat = dat>>1;                
		/* 写0和写1的时间至少要大于60us */
		if (testb)
		{                        
			HAL_GPIO_WritePin(GPIOx, GPIO_Pin, GPIO_PIN_RESET);
			/* 1us < 这个延时 < 15us */
			DS18B20_Delay(8);
			
			HAL_GPIO_WritePin(GPIOx, GPIO_Pin, GPIO_PIN_SET);
			DS18B20_Delay(58);
		}                
		else
		{                        
			HAL_GPIO_WritePin(GPIOx, GPIO_Pin, GPIO_PIN_RESET);
			/* 60us < Tx 0 < 120us */
			DS18B20_Delay(70);
			
			HAL_GPIO_WritePin(GPIOx, GPIO_Pin, GPIO_PIN_SET);             
			/* 1us < Trec(恢复时间) < 无穷大*/
			DS18B20_Delay(2);
		}
	}
}

/**
  * 函数功能: 跳过匹配 DS18B20 ROM
  * 输入参数: 无
  * 返 回 值: 无
  * 说    明:无
  */
static void DS18B20_SkipRom (GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
	DS18B20_Rst(GPIOx, GPIO_Pin);                   
	DS18B20_Presence(GPIOx, GPIO_Pin);                 
	DS18B20_WriteByte(0XCC, GPIOx, GPIO_Pin);                /* 跳过 ROM */        
}

/*
* 存储的温度是16 位的带符号扩展的二进制补码形式
* 当工作在12位分辨率时,其中5个符号位,7个整数位,4个小数位
*
*         |---------整数----------|-----小数 分辨率 1/(2^4)=0.0625----|
* 低字节  | 2^3 | 2^2 | 2^1 | 2^0 | 2^(-1) | 2^(-2) | 2^(-3) | 2^(-4) |
*
*
*         |-----符号位:0->正  1->负-------|-----------整数-----------|
* 高字节  |  s  |  s  |  s  |  s  |    s   |   2^6  |   2^5  |   2^4  |
*
* 
* 温度 = 符号位 + 整数 + 小数*0.0625
*/
/**
  * 函数功能: 在跳过匹配 ROM 情况下获取 DS18B20 温度值 
  * 输入参数: 无
  * 返 回 值: 温度值
  * 说    明:无
  */
float DS18B20_GetTemp_SkipRom (GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
	uint8_t tpmsb, tplsb;
	short s_tem;
	float f_tem;
	
	
	DS18B20_SkipRom (GPIOx, GPIO_Pin);
	DS18B20_WriteByte(0X44, GPIOx, GPIO_Pin);                                /* 开始转换 */
	
	
	DS18B20_SkipRom (GPIOx, GPIO_Pin);
	DS18B20_WriteByte(0XBE, GPIOx, GPIO_Pin);                                /* 读温度值 */
	
	tplsb = DS18B20_ReadByte(GPIOx, GPIO_Pin);                 
	tpmsb = DS18B20_ReadByte(GPIOx, GPIO_Pin); 
	
	
	s_tem = tpmsb<<8;
	s_tem = s_tem | tplsb;
	
	if( s_tem < 0 )                /* 负温度 */
		f_tem = (~s_tem+1) * 0.0625;        
	else
		f_tem = s_tem * 0.0625;
	
	return f_tem;         
}

main.c中的调用:

int main()
{
	//...
	while (1)
	{
		DS18B20_Init(GPIOA, GPIO_PIN_6);
		tempareture1 = DS18B20_GetTemp_SkipRom(GPIOA, GPIO_PIN_6);
	}
}

因为我只挂载了一个DS18B20(接在PA6上),所以不需要用到ROM和ID,这里直接跳过ROM读取温度的值。

参考

ROS第一步——Hello world

开始学习ROS啦,我以胡春旭编写的《ROS机器人开发实践》作为参考书籍。

首先是装了ubuntu 16.04系统,然后安装ROS,结果就安装ROS这一步就卡了一下午。

下载ROS需要添加镜像源,可以添加国内的也可以添加国外官方的镜像源,书上写的镜像源链接如下:

sudo sh -c 'echo "deb http://packages.ros/ros/ubuntu $(lsb_release -sc) main" > /etc/apt/sources.list.d/ros-latest.lis'

sudo sh -c '. /etc/lsb-release && echo "deb http://mirrors.ustc.edu.cn/ros/ubuntu $DISTRIB_CODENAME main" > /etc/apt/sources.list.d/ros-latest.lis'

sudo sh -c '. /etc/lsb-release && echo "deb http://mirror.sysu.edu.cn/ros/ubuntu $DISTRIB_CODENAME main" > /etc/apt/sources.list.d/ros-latest.lis'

但是我照做之后执行apt-update确是各种忽略,而且安装ros的时候报错找不到软件包。很明显是镜像源不对。这里就要吐槽一下网上的各种教程了,都是相互抄,没有一个能解决问题。

自闭了一下午才知道,原来镜像源中的$(lsb_release -sc)$DISTRIB_CODENAME 指的都是ubuntu的版本名称,我的是16.04版本,所以我的版本名称是xenial,所以我要把这两个东西替换为我的版本名称xenial,所以我的镜像源链接是:

deb http://mirror.sysu.edu.cn/ros/ubuntu xenial main

之后的安装就顺利多了。

安装完成之后就按照书籍的教程一步一步做,还挺顺利的,今天学习到了PublisherSubscriber,在这mark一下。

运行效果如下:
2019-03-09 23-04-41屏幕截图

电控基础知识

写在最前

本网页将我这一年(2018年)的电控经历积累下来的知识和经验,以及下半年进行的两次培训的内容和要点做了一个综合总结。

上半年我一直用的是标准库进行编程,但自从国庆期间用上HAL库之后,我被HAL库的便捷深深打动,自此基本放弃了标准库,所以本总结的内容是基于HAL库的,并结合STM32CubeMX软件(因为过程有些繁琐,截图很麻烦,所以本总结并不会贴出STM32CubeMX上的相关配置过程)。

GPIO

GPIO外设一共有八种模式,我比较熟悉的是这三种:上拉输入下拉输入推挽输出。上拉输入和下拉输入一般都和外部中断联系在一起,最典型的的应用就是读取按键的输入。推挽输出的功能是控制IO口的输出电平的高低,最常见的应用就是控制LED灯的亮灭。GPIO涉及的基本函数有以三个:

GPIO简介

HAL_GPIO_WritePin(GPIO_TypeDef * GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState);  //直接指定引脚的电平输出,用于推挽输出

HAL_GPIO_TogglePin(GPIO_TypeDef * GPIOx, uint16_t GPIO_Pin);  //直接翻转引脚的电平输出,无需指定输出电平,用于推挽输出

HAL_GPIO_ReadPin(GPIO_TypeDef * GPIOx, uint16_t GPIO_Pin);  //读取指定引脚当前的电平高低

函数功能见注释。前两个函数用于推挽输出,第三个函数用于输入功能。GPIO的输入还可以与外部中断联系起来,GPIO的外部中断回调函数如下:

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
  
  //用户自己的中断函数逻辑
  //your code here ...
}

这里简单说一下回调函数。HAL库的最大特点之一就是有很多的回调函数,几乎所有的中断都有对应的一种或多种回调函数。这些回调函数都被官方提前定义好了,只不过是**__weak**弱定义,我们要实现自己的回调函数逻辑只需要自己重新再定义一次这个回调函数,将它原来的弱定义覆盖即可。

EXTI

中断的概念大家应该都了解的差不多了,所以我就略过介绍中断这一环节。外部中断(EXTI)是STM32众多中断中的一种。STM32一共有16组外部中断,分别对应GPIOx.1~GPIOx.15(x=A,B,C,...,H,I),也就是说序号相同的IO口触发的是同一个外部中断,但是外部中断无法判断是由哪个GPIO外设所触发的,所以STM32最多可以监听16个外部中断,且触发这些外部中断的IO口序号不能相同

USART

STM32中有UARTUSART两种串口通信,USART比UART多出来的S代表的是“同步”,也就是说USART既支持同步通信,也支持异步通信,而UART只支持异步通信。但是同步通信在目前阶段的开发中几乎不会接触到,普遍用的还是它的异步通信功能。

USART简介

USART的标准接线有三根:TX、RX、GND,接线的时候要注意两块单片机的TX和RX要反接,因为发送(TX)对应着接收(RX)。个别同学可能图方便不接GND,甚至有些场景只接TX获RX一根线,这种情况虽然也可以通信成功,但是极不稳定,很容易发送失败。

因为USART的发送和接收数据分别使用一根线,所以USART串口通信支持全双工通信,即可以同时进行消息的发送和接收。USART的数据接收和发送都有三种方法,分别为中断DMA非中断非DMA。我推荐使用DMA接收,尤其是数据量很大的时候。这里列出了DMA方式的接收函数和非中断非DMA方式的发送函数。

HAL_UART_Transmit(UART_HandleTypeDef * huart, uint8_t * pData, uint16_t Size, uint32_t Timeout);  //串口发送函数,指定发送的字节数及其长度,同时指定发送超时时间

/*
 * @brief 串口DMA方式接收,会引发DMA中断。这句一定要在第一次接收中断之前执行一次
 * @param 串口号;接收串口数据的变量;接收的数据大小,接收到这么多字节之后才会引发中断
 * @retval HAL状态HAL_StatusTypeDef,可用于判断接收是否成功
 */
HAL_UART_Receive_DMA(UART_HandleTypeDef * huart, uint8_t * pData, uint16_t Size);

USART的发送和接收都可以设置是否触发中断,在标准库中串口通信的发送和接收中断函数是同一个,需要用户自己根据相关的寄存器状态判断是发送还是接收,而HAL库中已经写好了相关判断,并定义好了发送中断回调函数和接收中断回调函数。USART的接收中断回调函数如下:

void HAL_UART_RxCpltCallback(UART_HandleTypeDef * huart)
{
  HAL_UART_Receive_DMA(UART_HandleTypeDef * huart, uint8_t * pData, uint16_t Size);  //继续使能串口接收中断
  
  //用户自己的中断函数逻辑
  //your code here ...
}

大家可以注意一下这个中断回调函数的命名方式,其实很好记忆:

HAL UART Rx Cplt Callback
HAL库 串口 接收 Complete简写,接收完成 回调函数

大家如果看过HAL库的手册或者粗粗翻看过HAL库的头文件的话,会发现HAL库的回调函数特别丰富,不仅有完成回调函数,还有完成一半的回调函数,还有阻塞状态的回调函数等等,有兴趣的朋友可以自己去探索。

printf

另外这里介绍一下怎么在STM32中使用C语言中大名鼎鼎的printf函数。printf函数属于C语言自带的函数,需要在keil中勾选Use MicroLIB选项*(怎么勾选?百度一下,你就知道)*。然后在代码中的任何一个位置加入下面一段代码:

#pragma import(__use_no_semihosting)
//标准库需要的支持函数
struct __FILE
{
	int handle;
};
FILE __stdout;
//定义_sys_exit()以避免使用半主机模式
void _sys_exit(int x)
{
	x = x;
}
//重定义 fputc 函数
int fputc(int ch, FILE *f)
{
	while((USART1->SR&0X40)==0);//循环发送,直到发送完毕
	USART1->DR = (u8) ch;
	return ch;
}

上面的fputc函数是被printf函数调用的函数之一,只需要重定义它即可实现将printf的内容通过串口发送出去。

TIM

TIM分为高级定时器通用定时器基本定时器,其中基本定时器*(一般为TIM6和TIM7)的功能最简单,只有定时的功能,一般用作时钟基源(比如FreeRTOS操作系统的时钟基源);通用定时器在基本的定时功能的基础上多出了输出比较输入捕获功能,输出比较可以输出周期性的方波(比如PWM波和PPM波)*,输入捕获可以读取输入信号的高电平和低电平的时间,进而可以计算出信号的周期和占空比,这两者都应用十分广泛;高级定时器除了上述功能之外,还有还包含一些与电机控制和数字电源应用相关的功能,比方带死区控制的互补信号输出、紧急刹车关断输入控制等,这些功能可以用于控制高级的工业应用当中。

在我们的日常开发中只需要掌握通用定时器的定时、输出比较、和输入捕获功能就足够了。

定时功能

TIM最简单的功能就是定时功能,它对应的有一个中断函数,可以实现定时执行某个操作。TIM定时器要使用之前需要初始化,主要注意两个寄存器的值,一个是预分频寄存器的值,一个是ARR寄存器的值,这两个寄存器决定了定时器的计数周期,也就是定时器中断发生的频率。

这里要注意的是TIM定时器的定时属于硬件定时,是不允许超时的*(事实上不论是什么定时器都不建议超时)*,如果超时就会卡死程序。TIM定时器使用前要打开定时器,函数如下:

HAL_TIM_Base_Start_IT(TIM_HandleTypeDef * htim);  //定时器中断使能函数

对应的定时器中断回调函数为:

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef * htim)
{  
  //用户自己的中断函数逻辑
  //your code here ...
}

PWM

定时器的一个重要应用就是产生PWM波。PWM波的应用非常广泛,无论是舵机,还是蜂鸣器,还是航模电调,都是使用PWM驱动。PWM波有两个参数,一个是周期,一个是占空比,它们分别对应TIM的ARR寄存器和CCRx*(x是定时器的通道号)*寄存器。如果再程序中途想改变PWM波的占空比,需要直接操作寄存器,因为HAL库并没有相关函数。

TIM1->CCR1 = 1000;			//将TIM1的通道1对应的CCR1寄存器赋值为1000

这里可以给大家介绍一下影子寄存器的概念。我们用户所操作的所有寄存器,都不是真正起作用的寄存器,真正起作用的寄存器是影子寄存器。我们所操作的寄存器叫预装载寄存器,影子寄存器的值会随着预装载寄存器的改变而改变,但不是立即改变,其修改值只能通过更新事件才能生效。因为在定时器计数的过程中影子寄存器的值直接改变可能会引发错误。

使用定时器的PWM波功能也需要一个开启定时器的操作,函数如下:

HAL_TIM_PWM_Start(TIM_HandleTypeDef * htim, uint32_t Channel);  //PWM波产生使能函数

PWM波一般不会用到中断函数。

PWM_Read

我们一般都是产生PWM波去控制外设,但大家可以换个角度想一想,如果让我们自己来开发一个舵机呢,我们就需要读取输入的PWM波的占空比和周期,最笨的方法就是在死循环里面不断地扫面IO的电平,当电平变化的时候开始利用延时函数计时,这种方法效率不高且精度很低。另一种方法就是使用定时器的输入捕获功能。

定时器的输入捕获有三种模式:上升沿捕获下降沿捕获上升下降沿捕获 (有没有感觉非常像GPIO外部中断的触发方式?),这三种状态所对应的中断触发条件不一样。显然,如果我们要检测PWM波的高电平,则初始要设置为上升沿触发,触发之后改为下降沿触发。

那么怎么计时呢?定时器内部有一个不断在计数的寄存器CNT,计数方式又大体上分为向上计数向下计数中间计数。拿向上计数举例,CNT从0按预分频后的频率计数到ARR的值之后自动重载为0,然后继续计数。在输入捕获模式下,CNT的值会被赋值给CCRx,所以我们只需要在每次触发中断之后将CNT清零,然后在下一次中断里面获取CCRx的值,再结合定时器的频率就可以计算出对应的时间。

同上,使用输入捕获功能需要一个开启操作:

//在软件里面已经设置好了为上升沿触发模式
HAL_TIM_IC_Start_IT(&htim4, TIM_CHANNEL_1);    //这里是打开了TIM4通道1的输入捕获功能

相对应的输入捕获的中断回调函数为:

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
	//用户自己的中断函数逻辑
	//your code here...
}

这里给出一份读取PWM波高电平和低电平时间的代码:

/*
 * 使用的是TIM4的通道1作为输入捕获口
 * 预分频后定时器频率为1MHz,即计数周期为1微秒1次
 */

uint32_t pwm_high_level_time;    //PWM波高电平的时间
uint32_t pwm_low_level_time;     //PWM波低电平的时间
int tim_mode_raise_or_falling = 0;//0代表上升,1代表下降

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
  if(tim_mode_raise_or_falling == 0)			//如果是上升沿触发
  {
    pwm_low_level_time = HAL_TIM_ReadCapturedValue(&htim4,TIM_CHANNEL_1) + 1;		//记录低电平的时间(第一次触发该数值无效)
    __HAL_TIM_SET_COUNTER(&htim4,0);		//清零定时器计数CNT
    TIM_RESET_CAPTUREPOLARITY(&htim4, TIM_CHANNEL_1);		//重置定时器配置
    TIM_SET_CAPTUREPOLARITY(&htim4,TIM_CHANNEL_1,TIM_ICPOLARITY_FALLING);		//配置定时器为下降沿触发模式
    tim_mode_raise_or_falling = 1;		//中断模式标志位改变
  }
  else if(tim_mode_raise_or_falling == 1)			//如果是下降沿触发
  {
    pwm_high_level_time = HAL_TIM_ReadCapturedValue(&htim4,TIM_CHANNEL_1) + 1;		//记录高电平的时间
    __HAL_TIM_SET_COUNTER(&htim4,0);		//清零定时器计数CNT
    TIM_RESET_CAPTUREPOLARITY(&htim4, TIM_CHANNEL_1);		//重置定时器配置
    TIM_SET_CAPTUREPOLARITY(&htim4,TIM_CHANNEL_1,TIM_ICPOLARITY_RISING);		//配置定时器为上升沿触发模式
    tim_mode_raise_or_falling = 0;		//中断模式标志位改变
  }
}

细心的同学可能会发现,在记录时间的那条语句的最后有一个+1的操作,这里我引用一段我看到过的描述来解释这一现象(原文见https://wenku.baidu.com/view/dcd8f0f67f1922791688e8f6.html ):

PWM模式:
PWM边沿对齐PWM1模式,向上计数时,CCRx正确取值范围为(0-ARR):
CCRx = 0时,产生全无效电平(产生占空比为0%的PWM波形)
CCRx <= ARR时,产生CCRx个有效电平(产生占空比为 CCRx/(ARR+1)*100% 的PWM波形)。
CCRx > ARR时,产生全有效电平。
PWM边沿对齐PWM1模式,向下计数时,CCRx正确取值范围为(0~ARR):
CCRx = 0时,不能产生占空比 0% 的PWM波形(产生占空比为1/(ARR+1)*100%的PWM波形)。
CCRx <= ARR时,产生CCRx+1个有效电平(产生占空比为 (CCRx+1)/(ARR+1)*100% 的PWM波形)。
CCRx > ARR时,产生全有效电平。

捕获脉冲:
自动复位计数器方式下的PWM输入信号测量
在该模式下,可以方便地测试输入信号的周期(频率/转速)和占空比。
TIMx_CCR1的 寄存器值+1 就是周期计数值,TIMx_CCR2的 寄存器值+1 就是高电平计数值。
占空比=(TIMx_CCR2+1)/(TIMx_CCR1+1)*100%

从上面也可以看出,我们配置定时器的ARR的值的时候要减一个1,比方说我们想要周期为20000,那么ARR的值应该赋值为19999,因为CNT的数值是从0~19999,一共20000次计数。

ADC

STM32F1 系列芯片共两个ADC模块,每个ADC模块有9个通道,共18个通道。ADC的工作模式有单次模式连续模式扫描模式间断模式。本次培训主要讲解了连续模式的使用,并在DMA模式下读取数据。

ADC使用DMA时要注意选择Circular模式。另外ADC的读取到的有效数字是后十二位,对应的电压范围是0~3.3V,在中断函数中需要计算。涉及到的函数如下:

HAL_ADC_Start_DMA(ADC_HandleTypeDef * hadc, uint32_t * pData, uint32_t Length);  //开启使能ADC功能

大家可以发现,凡是HAL用于接收数据的函数基本都是这三个形参,调用的方法也是一样的套路,只是形参的数据类型可能不同。ADC的中断回调函数如下:

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef * hadc)
{  
  your code here ...
  //用户自己的中断函数逻辑
}

这个函数的名字也很好记,Conv代表Convert,转换的意思,也即ADC采集的数据转换完成之后的回调函数。

ADC我用的不是很多,所以懂得也不是很多,暂时就写这么多。

IIC

IIC是由Philip半导体公司发明的,用途广泛,很多外设都会使用到IIC通信。IIC通信只需要使用4根线,分别是VCCGNDCLKSDA。IIC总线上可以挂载多个设备,并分为主机从机,最多支持两个主机。每个设备都有唯一的地址,CLK线负责同步各个设备的时序,SDA负责传输数据,显然在IIC总线上同时只能存在一组通信,类似半双工通信。

通信的信号分为起始信号数据信号应答信号结束信号,主机在发送数据之前要先发送起始信号,发送结束之后要发送结束信号,从机每收到一个字节后会反馈一个应答信号,防止数据传输出错。

STM32F1 系列芯片共两个IIC模块,IIC开发最重要的东西就是时序图,只要看懂了时序图一切都显得极为简单。

IIC 的使用分为使用IO口模拟时序直接使用硬件寄存器两种方式实现IIC通信。虽然ARM公司为了避免Philip公司的IIC专利,将STM32里的IIC设计得很复杂,而且百度上一般都是使用IO口模拟时序,但是HAL库毕竟是那些设计硬件的工程师编写的,使用起来简直不要太方便,所以这里讲解使用STM32硬件自带的IIC模块的实现方式。

使用硬件IIC之后我们就不需要去理会那些时序图了,只需要注意需要发送的信息和从机的地址就行了。一般来说,IIC的外设的信号都分为两种,一种是命令,一种是数据。就拿OLED屏幕来说,移动光标的信息属于命令,显示的内容就属于数据。这方面的具体需求就要参考卖家提供的说明书或者示例代码了。

不管是发送命令还是发送数据,都会使用这个函数:

HAL_I2C_Mem_Write(I2C_HandleTypeDef * hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t * pData, uint16_t Size, uint32_t Timeout);  //将数据写入指定地址的从机

IIC我没有使用过相关的中断函数,IIC的中断无非也是数据发送一半中断回调、发送完成中断回调这么几种,与其他传输或接受数据的外设的套路都是一样的。

WWDG

STM32 分为独立看门狗窗口看门狗,其中独立看门狗时钟源独立,用于监控硬件异常,窗口看门狗时钟源不独立,用于监控软件异常,常用的是窗口看门狗。

窗口看门狗( Window Watch Dog Timer )的初始化与其他定时器的初始化有些许不同,需要的寄存器包括预分频寄存器窗口配置寄存器递减计数寄存器的重装载值。预分频就不说了,前面已经讲过了,窗口配置寄存器不能大于递减计数寄存器的值,它决定了为否操作的时间下限,当递减计数器大于窗口配置寄存器的值的时候,喂狗无效。递减计数器的递减到0x40(下窗口值)后自动重载,并将单片机复位。所以只有在递减计数器的值在0x40和窗口配置寄存器的值之间的时候,喂狗操作才有效,这就是喂狗时间的上限和下限,喂狗太频繁则无效,喂狗太慢则程序被复位。

WWDG涉及到的函数只有喂狗的函数:

HAL_WWDG_Refresh(WWDG_HandleTypeDef * hwwdg);  //重载窗口看门狗递减计数器(喂狗)

WWDG一般都不会用到它的中断函数,所以我也没用过。

FreeRTOS

在嵌入式领域中,嵌入式实时操作系统正得到越来越广泛的应用。采用嵌入式实时操作系统(RTOS)可以更合理、更有效地利用CPU的资源,简化应用软件的设计,缩短系统开发时间,更好地保证系统的实时性和可靠性。嵌入式操作系统有好几种,比如μC/OS-II、embOS、salvo、RTX,还有这里要说的FreeRTOS。

FreeRTOS是一个迷你的实时操作系统内核。作为一个轻量级的操作系统,功能包括:任务管理、时间管理、信号量、消息队列、内存管理、记录功能、软件定时器、协程等,可基本满足较小系统的需要。

我接触FreeRTOS时间不是很长,也没怎么去深入研究开发它的功能和用法,这里我使用的是由ST公司封装的CMSIS-RTOS API,这个API支持多种操作系统,而且使用方便。以下内容是从这篇讲解FreeRTOS的博客里面摘取的,但是这篇博客里面的一些函数的用法已经不适用于最新版本的库,望读者注意。

线程

操作系统与裸机的最大区别就是线程。在裸机系统中,除了while循环外,我们要在其他地方尽量避免死循环的存在,而在操作系统中,每一个线程都是一个死循环,FreeRTOS有一个强大的任务调度器,可以快速地切换各个任务并保存相应的上下文。

进程实例:

/* USER CODE BEGIN Header_StartDefaultTask */
/**
  * @brief  Function implementing the defaultTask thread.
  * @param  argument: Not used 
  * @retval None
  */
/* USER CODE END Header_StartDefaultTask */
void StartDefaultTask(void const * argument)
{

  /* USER CODE BEGIN StartDefaultTask */
  /* Infinite loop */
  for(;;)
  {
    
		osDelay(1);
  }
  /* USER CODE END StartDefaultTask */
}

线程与函数的最大区别在于,函数总归要返回到被调用处,而进程则是无限循环,不会主动结束。

在FreeRTOS里面,线程有八个优先级:

CMSIS-RTOS Priority Levels
osPriorityIdle
osPriorityLow
osPriorityBelowNormal
osPriorityNormal
osPriorityAboveNormal
osPriorityHigh
osPriorityRealTime
osPriorityError

上述优先级的从上到下依次增加*(其实从命名就能看出来)*,不同于裸机的定时器,在操作系统中线程的优先级可以是一样的,当两个线程的优先级是一样时,任务调度器会不断在这两个线程间来回切换,相当于两个线程同步执行,而且不用担心线程里面语句过多导致“超时”的情况。操作系统的优越性可见一斑。

线程的定义和初始化均可以在STM32CubeMX中完成。线程的使用流程如下:

osThreadId defaultTaskHandle;			//定义线程的ID,用于对线程的各种操作,比如修改优先级,中止、开始线程等
void StartDefaultTask(void const * argument);		//线程对应的函数体的声明
osThreadDef(defaultTask, StartDefaultTask, osPriorityNormal, 0, 128);		//线程定义,参数分别为:线程的名称,线程函数体,线程优先级,线程实例化个数,线程分配的栈空间
defaultTaskHandle = osThreadCreate(osThread(defaultTask), NULL);		//创建线程,并赋值给对应的线程ID

/* 线程的具体实现 */
void StartDefaultTask(void const * argument)
{

  /* USER CODE BEGIN StartDefaultTask */
  /* Infinite loop */
  for(;;)
  {
    	//这里写用户逻辑
		osDelay(1);
  }
  /* USER CODE END StartDefaultTask */
}

上面代码是我直接复制的,也就是说用软件配置生成好代码之后,线程的声明,定义,创建都已经完成,我们只需要在线程的函数体里面实现自己的逻辑就行了,可以说是非常方便。

信号量

光有线程有时候还不够,因为项目开发中线程往往不是相互独立的,需要不同的线程之间进行通信。在FreeRTOS中线程的通信可以使用信号量、队列、邮箱进行通信,这里只讲解最简单的信号量的使用,其他两种的使用可以参考这篇博客

信号量可以实现一个线程在另一个线程完成后再进行,也可以实现两个线程真正同步运行,线程可以发出一个信号量,也可以等待一个信号量。想象这样一个场景,线程B的运行需要线程A的预先执行,也就是线程B必须在线程A执行过后才能执行,怎么办呢?我们可以在线程A的函数最后发送一个信号量,然后在线程B的最前面等待该信号量。当线程B没有等待到信号量的时候,该线程处于挂起状态,直到信号量的到达才转为准备状态。

上述的流程的实现过程如下:

osSemaphoreId RemoteSignalHandle;		//定义信号量的ID
osSemaphoreDef(RemoteSignal);		//定义一个信号量,并命名为RemoteSignal
RemoteSignalHandle = osSemaphoreCreate(osSemaphore(RemoteSignal), 1);	//创建一个信号量实例,并赋值给对应的ID

/* 这里是一个线程,相关的线程定义步骤此处省略 */
void RCReceive(void const * argument)
{
  /* Infinite loop */
  for(;;)
  {
    osSemaphoreWait(RemoteSignalHandle, osWaitForever);		//等待信号量,等待时间为“永远”
	Remote.receiveData(&Remote, rc_data);	//等待到信号量之后执行的语句
  }
  /* USER CODE END RCReceive */
}

/*
 * 此示例代码使用中断函数发送信号量
 * 这样的好处是将复杂的数据处理逻辑放在线程中,尽量减少中断里面的函数执行时间
 */
void HAL_UART_RxCpltCallback (UART_HandleTypeDef *huart)
{
	HAL_UART_Receive_DMA(&huart1, rc_data, 18u);		//再次使能串口接收
	if(huart == &huart1)		//判断是否是由USART1触发的中断
	{
		osSemaphoreRelease(RemoteSignalHandle);		//发送信号量
	}
}

下面的代码可以实现两个线程同步进行:

/* 线程1 */
void Thead1(void const * argument)
{
	for(;;)
	{
		osSemaphoreRelease(Thead2_start);		//发送让线程2开始的信号量
		osSemaphoreWait(Thead1_start, osWaitForever);		//等待线程2开始后发出的让线程1开始的信号量
		Thead1_function();		//等待到线程2发送的信号量(即线程2开始执行后,执行线程1的逻辑,此时线程2的逻辑也同步开始)
	}
}

/* 线程2 */
void Thead2(void const * argument)
{
	for(;;)
	{
		osSemaphoreWait(Thead2_start, osWaitForever);		//等待线程1的信号量
		osSemaphoreRelease(Thead1_start);		//发送让线程1开始的信号量
		Thead2_function();		//线程2的具体逻辑
	}
}

延时函数

CMSIS-RTOS有自己的延时函数。大家应该还记得HAL的延时函数是HAL_Delay(uint32_t time),该延时函数是基于系统滴答定时器Systick实现的,但是该定时器的中断优先级很低,甚至低于操作系统的优先级,所以在线程使用该延时函数会出问题。

相信细心的朋友已经发现了,就在前面的代码中,有一个函数我没有讲过,没错,就是osDelay(uint32_t time)函数,这个函数能在线程中实现微秒级的延时。

在讲解该延时函数的作用之前,先来看看操作系统的任务调度器是怎么调度任务的。在FreeRTOS中,每一个线程都有四种状态:挂起阻塞就绪运行状态,每一种状态的特点从它的命名就可以猜出来。任务调度器在每一次切换任务的时候都会检查有没有优先级更高的线程处于就绪(ready)状态,如果有,则暂停当前执行的线程,转而执行优先级更高的线程。另外在FreeRTOS中,默认有一个空闲线程,它的优先级是最低的(osPriorityIdle),它是在没有任何一个用户的线程处于就绪或运行状态的时候运行。

在程序执行到osDelay(uint32_t time);这条语句后,当前任务被挂起,任务调度器转而判断其他哪个线程得以执行,当时间到了之后线程变为就绪状态,等待任务调度器调用,被执行的线程为运行状态。

虚拟定时器

虚拟定时器的功能相当于基本定时器,能实现最基本的定时执行一个回调函数,是通过软件实现的,所以叫做虚拟定时器,能实现毫秒级的定时执行。

虚拟定时器的使用方法如下:

osTimerId superviseTimerHandle;		//定义虚拟定时器的ID
osTimerDef(superviseTimer, supervise);		//定义一个虚拟定时器,指定了定时器的回调函数是supervise()
superviseTimerHandle = osTimerCreate(osTimer(superviseTimer), osTimerPeriodic, NULL);		//创建一个虚拟定时器实例,并指定了定时器模式为osTimerPeriodic模式(连续模式,还有一种模式是只执行一次的osTimerOnce)
osTimerStart(superviseTimerHandle, 2);		//启动虚拟定时器,配置定时器2毫秒执行一次

/* 虚拟定时器的回调函数 */
void supervise(void const * argument)
{
  /* USER CODE BEGIN supervise */
  //your code here...
  /* USER CODE END supervise */
}

要注意的是,虚拟定时器的回调函数和线程不一样,它不能有死循环,如果使用死循环当然没法实现定时执行啦。

从上面的讲解中大家可以发现,CMSIS-RTOS的API调用格式也都差不多,多练几次就行,很容易掌握的。

CAN

CAN是控制器局域网络(Controller Area Network, CAN)的简称,是由以研发和生产汽车电子产品著称的德国BOSCH公司开发的,并最终成为国际标准,是国际上应用最广泛的现场总线之一。

CAN的设计初衷是为适应“减少线束的数量”、“通过多个LAN,进行大量数据的高速通信”的需要,所以CAN通信只需要两根线:CANH和CANL,不需要VCC或GND,设备通过改变CANH和CANL之间的电位差也实现逻辑0和逻辑1的传输。

硬件要求

CAN通信需要硬件上配备一个专门的CAN收发器,还需要在总线两端各加入一个120Ω的电阻。这里提一下,经过本人的多次测试,虽然只有一个设备不能构成CAN总线,但是CAN外设的初始化也是可以顺利实现的,如果程序卡在了CAN的初始化处,那么80%是硬件层面出了问题,或者是引脚选错了。

CAN总线

理论上,CAN总线上可以挂载无数个设备,但因为CAN总线只有两根线,同时只能传输一份数据,也就是只能半双工通信,设备数量多了之后传输效率就降低了,而且CAN总线的传输频率与传输距离成反比。目前CAN总线最高可支持1M的传输速率,汽车上使用的比较多的有500K和250K的高速CAN,与125K和62.5K的低速CAN。

CAN总线上的每一个设备都有唯一的ID号,类似IIC的地址,但与IIC不同的是,CAN总线上没有主机和从机之分,每个设备都可以做主机,也都可以做从机。

CAN只有两根线,简陋的硬件设备必然对应了复杂的通信协议以保证数据的完整传输。这里引用百度百科的解释:

CAN数据帧由远程帧、错误帧和超载帧组成。
远程帧由6个场组成:帧起始、仲裁场、控制场、CRC场、应答场和帧结束。
错误帧由两个不同场组成,第一个场由来自各站的错误标志叠加得到,第二个场是错误界定符。
超载帧包括两个位场:超载标志和超载界定符。

更多具体的通信协议就不写出来了,反正这些都由CAN收发器实现了,我们用户只需要享受它带来的便捷就完事了。

CAN的初始化需要配置好CAN的总线传输速率,并配置CAN的过滤器。因为CAN通信是携带ID号的,总线上的每一个设备都会收到信息,设备根据信息里面带的ID号来判断是不是发给自己的信息,如果不是就忽略,这就是一个过滤的过程,用户配置过滤器可以实现接收某个或某些外设的消息,而忽略其他消息。

过滤器

CAN的过滤器无法在软件里面配置,需要手动写函数,这里贴出一个接收所有消息的过滤器配置函数~~(其实自己不太会)~~:

/**
* @brief CAN外设过滤器初始化
* @param can结构体
* @retval None
*/
HAL_StatusTypeDef CanFilterInit(CAN_HandleTypeDef* hcan)
{
  CAN_FilterTypeDef  sFilterConfig;

  sFilterConfig.FilterBank = 0;
  sFilterConfig.FilterMode = CAN_FILTERMODE_IDMASK;
  sFilterConfig.FilterScale = CAN_FILTERSCALE_32BIT;
  sFilterConfig.FilterIdHigh = 0x0000;
  sFilterConfig.FilterIdLow = 0x0000;
  sFilterConfig.FilterMaskIdHigh = 0x0000;
  sFilterConfig.FilterMaskIdLow = 0x0000;
  sFilterConfig.FilterFIFOAssignment = CAN_RX_FIFO0;
  sFilterConfig.FilterActivation = ENABLE;
  sFilterConfig.SlaveStartFilterBank = 14;
  
	if(hcan == &hcan1)
	{
		sFilterConfig.FilterBank = 0;
	}
	if(hcan == &hcan2)
	{
		sFilterConfig.FilterBank = 14;
	}
	
  if(HAL_CAN_ConfigFilter(hcan, &sFilterConfig) != HAL_OK)
  {
    Error_Handler();
  }

  if (HAL_CAN_Start(hcan) != HAL_OK)		//开启CAN
  {
    Error_Handler();
  }
	
  if (HAL_CAN_ActivateNotification(hcan, CAN_IT_RX_FIFO0_MSG_PENDING) != HAL_OK)
  {
    Error_Handler();
  }

	return HAL_OK;
}

使用步骤

CAN通信有两个邮箱用于发送和接收,每个邮箱对应一个中断回调函数,一般都选择邮箱0,下面列出CAN的使用步骤:

CanFilterInit(&hcan1);          //初始化CAN1过滤器

/**
 * @brief CAN通信接收中断回调函数
 * @param CAN序号
 * @retval None
 */
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
	CAN_RxHeaderTypeDef   RxHeader;
	if(HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &RxHeader, CanReceiveData) != HAL_OK)
  {
    Error_Handler();            //如果CAN通信数据接收出错,则进入死循环
  }
  CanDataReceive(RxHeader.StdId);   //进行电机数据解析
}

从代码中可以看出,初始化完成之后,只需要调用CAN的过滤器初始化函数即可使用CAN通信了。上面列出的是RX0对应的CAN接收中断函数HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)

CAN通信的发送函数如下*(此处以大疆的电机通信协议为例)*:

/**
* @brief ID为1~4的电机信号发送函数
* @param ID为1~4的各个电机的电流数值
* @retval None
*/
void CanTransmit_1234(CAN_HandleTypeDef *hcanx, int16_t cm1_iq, int16_t cm2_iq, int16_t cm3_iq, int16_t cm4_iq)
{
  CAN_TxHeaderTypeDef TxMessage;
	
	TxMessage.DLC=0x08;
  TxMessage.StdId=0x200;
  TxMessage.IDE=CAN_ID_STD;
  TxMessage.RTR=CAN_RTR_DATA;
  uint8_t TxData[8];
	
	TxData[0] = (uint8_t)(cm1_iq >> 8);
	TxData[1] = (uint8_t)cm1_iq;
	TxData[2] = (uint8_t)(cm2_iq >> 8);
	TxData[3] = (uint8_t)cm2_iq;
	TxData[4] = (uint8_t)(cm3_iq >> 8);
	TxData[5] = (uint8_t)cm3_iq;
	TxData[6] = (uint8_t)(cm4_iq >> 8);
	TxData[7] = (uint8_t)cm4_iq; 

	if(HAL_CAN_AddTxMessage(hcanx,&TxMessage,TxData,(uint32_t*)CAN_TX_MAILBOX0)!=HAL_OK)
	{
		 Error_Handler();       //如果CAN信息发送失败则进入死循环
	}
}

CAN通信真要理解清楚其实不容易,但是只学习怎么使用它的话,那是真的方便,用上就不想换了。

RMlib函数原型被我发现啦!!!

昨晚上无意间发现了一个博客,没想到居然遇到一位同在打RM的同道中人,这个博客里面干活还是挺多的,今天早上在我继续翻他的博客的时候,发现了我寻找了一年的RMLib里面的PID计算函数。

博客原文链接如下:
https://lonewolferic.github.io/2018/08/26/Implement-class-with-C/

这个还得从去年说起。

去年这个时候我刚处于机械转电控的转型期,刚上手RoboMaster官方开源的RM2016步兵车代码,里面有一个封装好的RMLib.lib库,这库里面的东西都看不见,只能通过官方提供的头文件获取库的接口,可以说是一个黑盒。后面我尝试了各种反编译的方法,但最后得到的也只有函数接口而已,还不如官方的头文件详细。

官方给的库有一个文件夹,文件夹结构如下:

+RMLib
--common.h
--fifo.h
--LostCounter.h
--pid_regulator.h
--ramp.h
--RMLib.lib

经过一个赛季的摸索,每个文件的功能也都摸索得差不多了,该复现的也基本复现了,但还是很好奇里面的代码究竟是怎么样的,而且总感觉自己写的代码或多或少有些缺陷,没有考虑周全。

后来RM官方又开源了ICRA的步兵车底层代码,在里面我看到了LostCounter.hramp.h的实现,看了一下跟我的差不多,有些小开心。

昨天时隔三年,官方又开源了新的RM2019的步兵车底层代码,我从中得知了fifo.h的实现方法,但因为我学疏才浅,C语言队列不是太了解,所以看到这份代码只能大喊“卧槽”。

终于,我今天看到了最后一个pid_regulator.c的实现,在此记录一下:

typedef struct PID_Regulator_t
{
	float ref;
	float fdb;
	float err[2];
	float kp;
	float ki;
	float kd;
	float componentKp;
	float componentKi;
	float componentKd;
	float componentKpMax;
	float componentKiMax;
	float componentKdMax;
	float output;
	float outputMax;
	float kp_offset;
	float ki_offset;
	float kd_offset;
	void (*Calc)(struct PID_Regulator_t *pid);
	void (*Reset)(struct PID_Regulator_t *pid);
}PID_Regulator_t;
void PID_Calc(PID_Regulator_t *pid)
{
    pid->err[1] = pid->err[0];
    pid->err[0] = pid->ref-pid->fdb;
    pid->componentKi += pid->err[0];
    if(pid->componentKi < -pid->componentKiMax)
    {
        pid->componentKi = -pid->componentKiMax;
    }
	else if(pid->componentKi > pid->componentKiMax)
    {
        pid->componentKi = pid->componentKiMax;
    }
	pid->output = pid->kp * pid->err[0] + pid->ki *pid->componentKi + pid->kp*(pid->err[0]-pid->err[1]);
	if ( pid->output > pid->outputMax )
    {
		pid->output = pid->outputMax;
	}
	else if ( pid->output < -pid->outputMax )
    {
		pid->output = -pid->outputMax;
	}
}
#define GIMBAL_MOTOR_PITCH_POSITION_PID_DEFAULT \
{\
	0,\
	0,\
	{0,0},\
	PITCH_POSITION_KP_DEFAULTS,\
	PITCH_POSITION_KI_DEFAULTS,\
	PITCH_POSITION_KD_DEFAULTS,\
	1,\
	0,\
	0,\
	4900,\
	1000,\
	1500,\
	0,\
	4900,\
	30,\
	0,\
	0,\
	&PID_Calc,\
	&PID_Reset,\
}\

非常nice!

UTC时间转换为本地时间

今天才注意到原来GitHub上面返回的时间是UTC时间。首先说说什么是UTC时间。

UTC时间

UTC时间,又称世界标准时间,是最主要的世界时间标准,其以原子时秒长为基础,在时刻上尽量接近于格林尼治标准时间(GMT)。

GMT 和 UTC 可以互换,但是实际上,GMT 是一个时区,而 UTC 是一个时间标准。

UTC时间一般格式为:"2010-05-04T05:52:33Z"

UTC时间与北京时间相差了八个小时。

解决办法

参考了网上教程Unix时间戳在线转换工具后,成功解决了时间差的问题。代码如下:

function utc2localTime(time) {
    var time_string_utc_epoch = Date.parse(time);
    var unixTimestamp = new Date(time_string_utc_epoch);
    return unixTimestamp.toLocaleString();
}

我简单封装为一个函数,传入的参数就是UTC时间,返回了浏览器本地的时间,效果如下:
运行效果

NICE!

STM32 flash读写

随着代码结构的完善,越来越希望代码更加智能化,调参的步骤越少越好,要是啥也不用调就更好了。。。

回忆起以往看过的代码,感觉智能化第一步就是搞定flash读写。有了flash之后,一切都变得很方便了。

比如每辆车的电机安装方式不一样,那么云台中间位置就不一样,如果有了flash,直接运行校准程序,云台左右分别碰到限位开关,自动计算出中间位置,pitch轴直接用手扶正,靠陀螺仪判断是否水平,水平的时候就记下当前位置,写入flash中,不用再慢吞吞地进调试,手动记录中间位置了。

又比如现在都流行自己搞上位机,上位机校准的关键一点就是单片机需要存储上位机的信息,下次上电就可以直接使用了。

所以flash多好啊。

flash

首先我们需要了解一个内存映射:

image

stm32的flash地址起始于0x0800 0000,结束地址是0x0800 0000加上芯片实际的flash大小,不同的芯片flash大小不同。

RAM起始地址是0x2000 0000,结束地址是0x2000 0000加上芯片的RAM大小。不同的芯片RAM也不同。

Flash中的内容一般用来存储代码和一些定义为const的数据,断电不丢失,
RAM可以理解为内存,用来存储代码运行时的数据,变量等等。掉电数据丢失。

STM32将外设等都映射为地址的形式,对地址的操作就是对外设的操作。

STM32的外设地址从0x4000 0000开始,可以看到在库文件中,是通过基于0x4000 0000地址的偏移量来操作寄存器以及外设的。

一般情况下,程序文件是从 0x0800 0000 地址写入,这个是STM32开始执行的地方,0x0800 0004是STM32的中断向量表的起始地址。

在使用keil进行编写程序时,其编程地址的设置一般是这样的:

image

程序的写入地址从0x08000000(数好零的个数)开始的,其大小为0x100000也就是1024K的空间,换句话说就是告诉编译器flash的空间是从0x08000000-0x08100000。这与STM32的内存地址映射关系是对应的。

内部flash的构成

STM32 的内部 FLASH 包含主存储器、系统存储器、 OTP 区域以及选项字节区域,它们的地址分布及大小如下:

image

  • 主存储器:一般我们说 STM32 内部 FLASH 的时候,都是指这个主存储器区域它是存储用户应用程序的空间,芯片型号说明中的 1M FLASH、 2M FLASH 都是指这个区域的大小。与其它 FLASH 一样,在写入数据前,要先按扇区擦除
  • 系统存储区:系统存储区是用户不能访问的区域,它在芯片出厂时已经固化了启动代码,它负责实现串口、 USB 以及 CAN 等 ISP 烧录功能。
  • OTP 区域:OTP(One Time Program),指的是只能写入一次的存储区域,容量为 512 字节,写入后数据就无法再更改, OTP 常用于存储应用程序的加密密钥。
  • 选项字节:选项字节用于配置 FLASH 的读写保护、电源管理中的 BOR 级别、软件/硬件看门狗等功能,这部分共 32 字节。可以通过修改 FLASH 的选项控制寄存器修改。

查看工程内存的分布

由于内部 FLASH 本身存储有程序数据,若不是有意删除某段程序代码,一般不应修改程序空间的内容,所以在使用内部 FLASH 存储其它数据前需要了解哪一些空间已经写入了程序代码,存储了程序代码的扇区都不应作任何修改。

通过查询应用程序编译时产生的*.map后缀文件,
打开 map 文件后,查看文件最后部分的区域,可以看到一段以 Memory Map of the image开头的记录:

image

从这个文件中可以看到flash究竟哪些地址被使用了。keil在下载程序的时候有三种选项:

image

  • Erase Full Chip:烧写程序之前擦除整个Flash存储器。
  • Erase Sectors:烧写程序之前擦除程序要使用的扇区。
  • Do not Erase:不进行擦除操作

默认选择第二个选项,所以我们只需要把数据存储在程序没有用到的flash区域就行了,不会在下载程序的时候被覆盖。

代码实践

STM32CubeMX中不要任何特殊的配置,flash相关操作函数默认自带了,直接贴上代码:

/*FLASH写入程序*/
void writeFlashTest(uint32_t addr, uint32_t WriteFlashData)
{
	/* 1/4解锁FLASH*/
	HAL_FLASH_Unlock();

	/* 2/4擦除FLASH*/
	/*初始化FLASH_EraseInitTypeDef*/
	FLASH_EraseInitTypeDef FlashSet;
	FlashSet.TypeErase = FLASH_TYPEERASE_SECTORS;
	FlashSet.NbSectors = 1;
	FlashSet.Sector = FLASH_SECTOR_7;
	FlashSet.VoltageRange = FLASH_VOLTAGE_RANGE_3;
	
	/*设置PageError,调用擦除函数*/
	uint32_t PageError = 0;
	HAL_FLASHEx_Erase(&FlashSet, &PageError);

	/* 3/4对FLASH烧写*/
	HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, WriteFlashData);

	/* 4/4锁住FLASH*/
	HAL_FLASH_Lock();
}

/*FLASH读取程序*/
uint32_t printFlashTest(uint32_t addr)
{
	uint32_t temp = *(__IO uint32_t*)(addr);
	return temp;
}

上面的程序中读取程序很简单,直接读取对应地址的数值就行了,写入数据也很简单,直接调用HAL_FLASH_Program()函数就行了,关键是flash擦除这一步有一些配置选项。

flash写入之前一定要先擦除,否则可能会导致写入失败,或写入数据错误。flash擦除需要初始化FLASH_EraseInitTypeDef结构体,定义如下:

/**
  * @brief  FLASH Erase structure definition
  */
typedef struct
{
  uint32_t TypeErase;   /*!< Mass erase or sector Erase.
                             This parameter can be a value of @ref FLASHEx_Type_Erase */

  uint32_t Banks;       /*!< Select banks to erase when Mass erase is enabled.
                             This parameter must be a value of @ref FLASHEx_Banks */

  uint32_t Sector;      /*!< Initial FLASH sector to erase when Mass erase is disabled
                             This parameter must be a value of @ref FLASHEx_Sectors */

  uint32_t NbSectors;   /*!< Number of sectors to be erased.
                             This parameter must be a value between 1 and (max number of sectors - value of Initial sector)*/

  uint32_t VoltageRange;/*!< The device voltage range which defines the erase parallelism
                             This parameter must be a value of @ref FLASHEx_Voltage_Range */

} FLASH_EraseInitTypeDef;

第一个成员变量代表擦除方式,有两个方式选择:

/** @defgroup FLASHEx_Type_Erase FLASH Type Erase
  * @{
  */ 
#define FLASH_TYPEERASE_SECTORS         0x00000000U  /*!< Sectors erase only          */
#define FLASH_TYPEERASE_MASSERASE       0x00000001U  /*!< Flash Mass erase activation */

这里我选择的是sector方式擦除。可见flash的擦除操作不能单独擦除一个地址上的数据。

第二个成员变量是当进行块擦除的时候使用的,这里不管。

第三个成员变量指定要擦除的sector,F4系列的flash sector划分如下:

image

第四个变量指定要擦除的sector个数。

我的flash目标地址是0x0807E000,属于Sector 7,所以我只需要擦除一个sector就行了。

第五个变量是flash的工作电压,电压越高,擦除效率越高,一次性擦除的数据就越多,可选项如下:

/** @defgroup FLASHEx_Voltage_Range FLASH Voltage Range
  * @{
  */ 
#define FLASH_VOLTAGE_RANGE_1        0x00000000U  /*!< Device operating range: 1.8V to 2.1V                */
#define FLASH_VOLTAGE_RANGE_2        0x00000001U  /*!< Device operating range: 2.1V to 2.7V                */
#define FLASH_VOLTAGE_RANGE_3        0x00000002U  /*!< Device operating range: 2.7V to 3.6V                */
#define FLASH_VOLTAGE_RANGE_4        0x00000003U  /*!< Device operating range: 2.7V to 3.6V + External Vpp */

参考

多摩川驱动步进电机总结

终于终于,我的这个外接小项目完成了。回过头来一看其实很简单,也就两部分,一部分是步进电机,一部分是编码器,今天就完成最后一步,把两者组合起来。

系统框图

我这个小demo的系统框图如下:
default

程序逻辑

小项目的要求是实现步进电机与编码器同步旋转,所以需要实现步进电机的位置环控制,而因为我使用了步进电机驱动器TB6600,只需要发送一定数目的脉冲就可以了,反正我这也只是一个demo,所以也就没有弄什么角度传感器,直接开环控制。

单脉冲模式

所以问题的关键就在如何产生指定数目的脉冲。我这里用到了TIM的单脉冲模式,也就是默认启动PWM后只产生一个脉冲,因为它默认一个周期后自动失能,这个时候需要再次使能PWM才能产生下一个脉冲。

我这里使用的是TIM5的CH1通道,周期1ms,占空比50%,单脉冲模式。

根据编码器值控制步进电机角度

直接拿代码说话:

我定义了一个RotateMotor()函数:

void RotateMotor(void)
{
	int32_t rotate = GetEncoderBias()>>15;
	if(rotate > round_count)
	{
		anticlockwise();
	}
	else if(rotate < round_count)
	{
		clockwise();
	}
}

第一句话调用了GetEncoderBias()函数:

int32_t GetEncoderBias(void)
{
	int32_t position = ti.rx.abs + ti.rx.abm*(1<<23);     //abs为单圈数据,abm为圈数
	if(encoderInitPosition == 0)
	{
		encoderInitPosition = position;
		return 0;
	}
	else
	{
		return (position - encoderInitPosition);
	}
}

这个函数返回了一个编码器相对于刚通电时的位置旋转的角度。因为编码器是23位的,而我步进电机设定的步距为1.8°,也就是200个脉冲转一圈,所以我将获得的偏差角度右移了15位,让它与步进电机的步距相匹配。

round_count参数是记录步进电机角度变化的一种方式:

void anticlockwise(void)
{
	round_count ++;
	HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET);      //逆时针旋转
	Rotate();
}

void clockwise(void)
{
	round_count --;
	HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET);         //顺时针旋转
	Rotate();
}

Rotate()函数就更简单了,只需要使能PWM就行了:

void Rotate(void)
{
	HAL_TIM_PWM_Start(&htim5, TIM_CHANNEL_1);
}

效果展示

代码真的很简单,但因为自己水平不足,中途踩了很多坑,导致这个小项目前前后后用了七天才搞定,还得继续努力才行。

最后附上我的demo效果:
demo

JTAG与SWD接线口转换

开发STM32时常在20pin的JTAG接口和4pin的SWD接口之间转换,但时间一长就记不住它们的线序对应关系,每次都去百度找感觉很麻烦,这次直接把它录进来。
image

修改url而不刷新页面

最近有用户反映gitblog中的用户登录功能非常不稳定,有时能登录,有时不能登录。

看似是个小问题,结果自闭了两天。仔细debug发现是如下代码出了问题:

var token = getUrlParam('access_token');
if(token != undefined && token != null) {
    window.localStorage.clear();
    window.localStorage.setItem("access_token",token);
    window.location.href = window.location.origin + window.location.pathname + "?id="+getUrlParam('id');
}

window.localStorage.setItem("access_token",token)这句话执行需要一定的时间,而JavaScript默认支持异步通信,所以token还没有存储进localStorage页面就已经跳转了,导致登录失败。

其实不跳转问题也不是很大,但用户的access_token直接显示在浏览器的url中总感觉有些不妥当,所以才进行跳转。

所以其实只要能将url中的access_token去掉就行了。

history.pushState()

隆重介绍HTML5 新增的API,可以在不刷新页面的情况下新增一条历史记录,同时更改url中的内容。

这个函数接受三个参数:

  • state:一个与指定网址相关的状态对象,pushstate事件触发时,该对象会传入回调函数。如果不需要这个对象,此处可以填null。
  • title:新页面的标题,但是所有浏览器目前都忽略这个值,因此这里可以填null。
  • url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。

所以我上面的代码修改如下:

var token = getUrlParam('access_token');
if(token != undefined && token != null) {
    window.localStorage.clear();
    window.localStorage.setItem("access_token",token);
    history.pushState(null, config.title, 'content.html?id='+getUrlParam('id'));
}

history.replaceState()

但是上面的代码还有个小问题,在移动端操作的时候,登录后退出网页会回退到含有access_token的url页面,这样用户在退出的时候会多点一次退出,用户体验不佳,所以我把history.pushState()换成了history.replaceState()。修改后的代码如下:

var token = getUrlParam('access_token');
if(token != undefined && token != null) {
    window.localStorage.clear();
    window.localStorage.setItem("access_token",token);
    history.replaceState(null, config.title, 'content.html?id='+getUrlParam('id'));
}

经测试,完美解决!

机器学习-单变量线性回归

符号说明

符号 含义
m 训练样本的数目
x 特征/输入变量
y 目标变量/输出变量
(x,y) 训练集中的实例
h(hypothesis) 学习算法的解决方案或函数

hypothesis function

image

代价函数

如何确定hypothesis function中的两个参数呢?如何评估这个函数的效果是否好呢?需要用到代价函数(cost function):

我们的目标便是选择出可以使得建模误差的平方和能够最小的模型参数。 即使得代价函数为:
image

代价函数也被称作平方误差函数,有时也被称为平方误差代价函数。我们之所以要求出误差的平方和,是因为误差平方代价函数,对于大多数问题,特别是回归问题,都是一个合理的选择。

还有其他的代价函数也能很好地发挥作用,但是平方误差代价函数可能是解决回归问题最常用的手段了。

image

梯度下降

梯度下降是一个用来求函数最小值的算法,我们将使用梯度下降算法来求出代价函数的最小值。

image

想象一下你正站立在山的这一点上,站立在你想象的公园这座红色山上,在梯度下降算法中,我们要做的就是旋转360度,看看我们的周围,并问自己要在某个方向上,用小碎步尽快下山。这些小碎步需要朝什么方向?如果我们站在山坡上的这一点,你看一下周围,你会发现最佳的下山方向,你再看看周围,然后再一次想想,我应该从什么方向迈着小碎步下山?然后你按照自己的判断又迈出一步,重复上面的步骤,从这个新的点,你环顾四周,并决定从什么方向将会最快下山,然后又迈进了一小步,并依此类推,直到你接近局部最低点的位置。

批量梯度下降(batch gradient descent)算法的公式为:

image

其中α是学习率,它决定了我们沿着能让代价函数下降程度最大的方向向下迈出的步子有多大。

泛化梯度下降算法

多变量线性回归的批量梯度下降算法为:
image

即:
image

求导数后得到:

其中
image可以写成
image

python计算代码:

def computeCost(X, y, theta):
inner = np.power(((X * theta.T) - y), 2)
return np.sum(inner) / (2 * len(X))

特征缩放

上图的图像显得很扁,梯度下降算法需要非常多次的迭代才能收敛。解决的方法是尝试将所有特征的尺度都尽量缩放到-1到1之间。

正规方程

到目前为止,我们都在使用梯度下降算法,但是对于某些线性回归问题,正规方程方法是更好的解决方案。

正规方程可以一次性求解θ的最佳值,计算公式如下:
image

辅助瞄准初步实现

时间过得很快,转眼寒假就结束了,又是新的一轮调车。寒假前成功与视觉组对接,接收到了视觉组发送的角度信息。是时候用上它了。

视觉组发送的角度是相对角度,比如现在我的云台在3点钟方向,目标在2点钟方向,视觉传递给电控的数据就是-30°,即让云台向左转动30度。

视觉计算出的角度很大程度上依赖于摄像头的单目测距,但往往总是差一点距离,导致角度不是完全准确,所以我实现辅助瞄准的时候大部分时候都在强行校准角度信息。

辅助瞄准基本实现

先说说我是怎么实现辅助瞄准的。

就拿yaw轴来说事,在我的代码中,云台电机位置环PID的反馈值是编码器的值,也就是real_position,具体的可以查看我前面写的文章。在正常模式下,云台电机的位置环期望值是由遥控器控制的,在辅助瞄准模式下,这个期望值则由视觉的角度数据确定。实现代码很简单:

if(hero.workstate == RemoteControl)         //遥控器控制模式
{
        if(Remote.rc.s1 == 2 && !Is_Error(1<<12))
	{
		hero.gimbal.YawAngle = yaw_motor.fdbPosition - PC_Data.yaw * 22.755f;
		hero.gimbal.PitchAngle = pitch_motor.real_position - PC_Data.pitch * 22.755f;
	}
	else
	{
		hero.gimbal.YawAngle = hero.gimbal.YawBiasAngle - (Remote.rc.ch2 - 1024);
		hero.gimbal.PitchAngle = hero.gimbal.PitchBiasAngle + (Remote.rc.ch3 - 1024);
	}
}

我直接在当前云台编码器的基础上进行角度的加减,代码中的22.755是单位换算,视觉的数据单位是°,而编码器一圈是8192,之间的转换倍数是22.755。

至于角度到底是加还是减则要看实际的摄像头和电机的安装方式确定。

角度修正

如果按照上述的方式实现,假设目标机器人和击打的机器人的位置是静止不动的,那么云台位置环的期望值应该是一个定值,这样云台才能最快速地指向目标。可以想象,如果视觉计算出的角度偏小,那么云台响应不及时,如果角度偏大,则云台会超调,严重会发生振荡。

实际情况是由于单目测距的结果偏大,导致角度偏小,那么我是怎么发现这个问题的呢?其实很简单,只需要采集两组数据就行了。

比如当目标静止时,采集到了以下两组数据:

yaw轴编码器值 视觉计算的角度
2200 -15
1500 10

两组数据的编码器差值是700,换算成角度为30.762°,而视觉测得的角度差为25°,所以可以认为视觉的角度小了1.23倍,于是相关的代码应该手动修改为:

hero.gimbal.YawAngle = yaw_motor.fdbPosition + PC_Data.yaw * 27.988f;

但这样始终不是万全之策,因为视觉的测距偏差不是理想的呈现倍数变化,所以在2m处标定的数据在瞄准5m处的目标时就会出现问题。

存在的问题

上面说到的角度不准确是问题之一,另一个严重的问题,也是困扰广大参赛队的问题,就是延时问题,可以上一个动态图给大家感受一下:

automatic_aim

重点关注上图的左侧部分,可以明显看到瞄准有延迟,在面对高速变化移动的装甲板时基本瞄不准。这里有多方面的原因,首先是摄像头的固有延时,对于摄像头的延时的测量,可以直接在电脑屏幕上打开一个时钟,然后对比摄像头与屏幕中的时钟的时间差,基本上就是摄像头的固有延时,经过测试,摄像头的延时大概60ms,对于这个问题,加钱才能解决。

第二原因是识别算法的耗时,视觉算法处理一帧图像大概20ms,。这个解决办法是加入一些滤波算法。

还有一个原因就是电控的锅了,电机的响应不够及时。这个可以通过调整电机的PID参数解决,我的经验是将速度环的参数P往小调,将位置环的参数P往大调,一个减小,一个增大,能维持平衡。

改进方向

接下来的改进方向当然还是分为电控和视觉两个方面。

电控方面

  • 加一点简单的滤波,让数据更加平滑
  • 优化PID参数,加快响应速度
  • 完善意外情况的处理逻辑,比如丢失目标时、切换状态时的逻辑处理等

视觉方面

  • 估计得加卡尔曼滤波处理一下

寻路算法学习

前几天搞美赛,题目是怎么逃离卢浮宫最快,感觉应该是一个路径规划的问题。于是死磕了几天的寻路算法。

寻路算法主要见于即时战略游戏(RTS)中,分为静态路径寻路和动态路径寻路,这里只讨论静态寻路。

静态寻路算法中最常见的就是A-Star算法Dijkstra算法,其中A-Star算法是基于Dijkstra算法的。

Dijkstra算法

Dijkstra算法又称为最短路径算法,是由荷兰著名的计算机科学家Dijkstra提出来的。它是一种集中式的静态算法,用来求解图中指定节点到其他节点的最短距离。

Dijkstra算法的具体算法如下:

如下图,首先以终点(图中红圈处)为原点,然后访问四周相邻的方格,如果不是障碍块,则给改方格记录上从终点到达该方格的步数。接下来在此基础上进一步访问更多的方格,直至遍历完所有的方格。

image

可以查看这个在线示例,如果没有蓝线则说明没有找到路径,刷新页面即可(因为障碍是随机生成的,不一定能找到路径)。

A-Star算法

1968年,Hart,Nillsion等提出了基于启发式函数的最短寻路算法A-Star,用于解决静态路由网中的最短路径问题。它与Dijkstra算法同属于求最短路径搜索算法,但是A-Star算法使用了启发式信息,不会盲目的搜索路径,而是评估地图上任意点到目标点的费用,更好地选择搜索方向,优先搜索最有希望的节点。A-Star算法通过估价函数(Heuristic Function)$$f(h)$$来估计图中的当前点S到目标点G的距离,并由此决定它的搜索方向,其公式表示为:

其中n代表地图上的某个节点,g(n)是从起始点S到当前节点n的实际花费,h(n)为当前节点n到目标节点的最佳路径代价估计,用于估计当前节点到目标节点的最佳距离,而并非是实际的距离,估计得时候并不需要考虑地图中的障碍物的存在。常用的距离估计有:曼哈顿距离、欧几里得距离、平方欧式距离、对角距离等。不同的估计方法使用与不同的地图场景中,这也是A-Star算法的一大缺点,如果估计方法选择的不对,那么可能找不出最短路径。

image

上图展示的是一个使用A-Star算法计算出的最短路径,方格中有两个数字,第一个数字g(n),第二个数字是h(n),从上图可以清晰地看出A-Star算法的工作过程。

关于A-Star算法的讲解,这个网址讲解的非常详细:
Introduction to the A* Algorithm

最后贴上一些在线示例,这些示例全部来自博客How To RTS

最基本的Dijkstra算法查找的路径
基本的Flow Field模型
加入物理引擎BOX2D后的Flow Field模型
加入Continuum Crowds后的模型

Electron学习(三)

数据存储

electron数据存储有多种方式,其中最简单的就是使用最基础的Web API:localStorage

index.html

<html>
    <head>
        <title>笔记本</title>
        <link rel="stylesheet" type="text/css" href="index.css">
        <script src="event.js"></script>
    </head>
    <body>
        <div id="close" onclick="quit();">x</div>
        <textarea id="textarea" onKeyUp="saveNotes();"></textarea>
    </body>
</html>

index.css

body {
    background: #E1FFFF;
    color: #694921;
    padding: 1em;
}

textarea {
    font-family: 'Hannotate SC', 'Hanzipen SC','Comic Sans', 'Comic Sans MS';
    outline: none;
    font-size: 18pt;
    border: none;
    width: 100%;
    height: 100%;
    background: none;
}

#close {
    cursor: pointer;
    position: absolute;
    top: 8px;
    right: 10px;
    text-align: center;
    font-family: 'Helvetica Neue', 'Arial';
    font-weight: 400;
}

event.js

const electron = require('electron');
const app = electron.remote.app;
//初始化页面
function initialize () {
        //从 localStorage 中获取保存的笔记
    let notes = window.localStorage.notes;
    if (!notes) notes = '记录生活的点点滴滴...';
       //将保存的笔记显示在文本输入区域
       textarea.value = notes;
}
function saveNotes () {
    let notes = textarea.value;
        //保存输入的笔记
    window.localStorage.setItem('notes',notes);
}
//退出笔记本
function quit () { app.quit(); }
window.onload = initialize;

尽管 Electron 应用可以利用 localStorage API 将数据以键值的形式保存在客户端,但 localStorage 并不适合存储大量的数据,而且 localStorage 是通过键值存储数据的,比较适合保存配置信息,而不是海量的结构化数据,如二维表。

因此,要想在 Electron 客户端保存更复杂的数据而且便于检索,就需要使用真正的数据库,如 SQLite、MySQL。

SQLite 数据库

SQLite数据库是本地数据库,会直接在本地生成.db文件。

SQLite 支持多种接口,如 Java、C++、C、Python、JavaScript 等,Node.js 也支持多种方式操作 SQLite 数据库,其中最简单的就是使用 sql.js。

let sql = require('./sql.js');
let db = new sql.Database();

接下来可以使用 run 方法执行 SQL 语句,代码如下

let sql = ...
db.run(sql)

run 方法可以执行多种 SQL 语句,如创建表、插入、更新、删除等;如果要查询记录,可以使用 exec 方法,代码如下:

let rows = db.exec("select * from table1");

要注意的是,前面的所有操作都是在内存中完成的,到现在为止,还没有将 SQLite 数据库保存在硬盘中,因此在执行创建表、插入、更新、删除等任何修改数据的操作后,应该使用下面的代码将内存中的数据库作为文件保存在硬盘中。

//获取 SQLite 数据库的二进制数据
var binaryArray = db.export();
var fs = require('fs');
fs.writeFile("test.db", binaryArray, "binary", function (err) {
       ... ...
});

MySQL数据库

在 Node.js 中访问 MySQL 数据库需要使用 mysql 模块,该模块不是 Node.js 的标准模块,因此需要在 Electron 工程根目录执行下面的命令安装。

npm install --save mysql

首先用下面的代码导入 mysql 模块。

const mysql = require('mysql');

然后使用下面的代码连接 MySQL 数据库。

conn = mysql.createConnection({
    host: MySQL服务器的IP或域名,
    user: 用户名,
    password: 密码,
    database: 数据库名,
    port: 3306   //MySQL 的默认端口号
});

接下来可以使用 conn.query 方法执行 SQL 语句。

let updateSQL = ...
conn.query(updateSQL, function (err, result) {
    if (err) console.log(err);
    else {
        console.log('success!');
    }
});

打包和发布应用

electron-packager 是一款非常强大的 Electron 应用打包工具,是开源免费的。

使用下面的命令将 electron-packager 安装到当前工程中:

npm install electron-packager --save-dev

或者使用下面的命令全局安装 electron-packager。

npm install electron-packager -g

electron-packager 支持如下两种使用方式:

  • 命令行
  • API

命令行使用

在项目根目录下进入命令行:

electron-packager . --electron-version=3.0.2

其中 electron-packager 命令后面的点(.)表示要打包当前目录的工程,后面的 --electron-version 命令行参数表示要打包的 Electron 版本号,注意,这个版本号不是本地安装的 electron 版本号,而是打包到安装包中的 electron 版本,但建议打包的 Electron 版本尽量与开发中使用的 Electron 版本相同,否则容易出现代码不兼容的问题。

在打包的过程中,electron-packager 会下载指定的 Electron 安装包。

但这样打包出来的应用,其中的图片等资源是显示不出来的(只针对资源不在项目目录下的)。

解决方法一种是直接手动把资源复制到指定的目录下;一种是在打包之前就把资源移动到项目目录下;还有一种就是直接使用网络资源。

修改应用程序图标

只需要找一个 ico 文件,并使用下面的命令打包即可

electron-packager .  me  --icon=D:\MyStudio\resources\electron\images\folder.ico  --electron-version=3.0.2

使用 electron-packager-interactive

使用 electron-packager 工具打包需要指定多个命令行参数,比较麻烦,为了方便,可以使用 electron-packager 交互工具 electron-packager-interactive,这个程序也是一个命令行工具,执行 electron-packager-interactive 后,会在控制台一步一步提示该如何去做。

使用下面的命令安装 electron-packager-interactive。

npm install  electron-packager-interactive -g

执行 electron-packager-interactive 命令,会一步一步提示应该如何做,如果要保留默认值,直接按 Enter 键即可,如果需要修改默认值,直接在控制台输入新的值即可。

硬件错误HardFault_Handler的问题查找

昨天和今天兴致冲冲地重构了代码结构,结果今天下午烧程序一看,连灯都不闪,当场崩溃。进入调试后发现程序进入了下面的死循环中:

/**
  * @brief This function handles Hard fault interrupt.
  */
void HardFault_Handler(void)
{
  /* USER CODE BEGIN HardFault_IRQn 0 */

  /* USER CODE END HardFault_IRQn 0 */
  while (1)
  {
    /* USER CODE BEGIN W1_HardFault_IRQn 0 */
    /* USER CODE END W1_HardFault_IRQn 0 */
  }
}

进入这个函数的原因可能是以下几种:

  1. 数组越界操作;
  2. 内存溢出,访问越界;
  3. 堆栈溢出,程序跑飞;
  4. 中断处理错误;

程序那么大,想自己查找问题无疑是大海捞针,毕竟每一个位置我都觉得是完美无缺的。

解决办法如下:

硬件中断函数HardFault_Handler里的while(1)处打调试断点,程序执行到断点处时会停止仿真。

image

在Keil菜单栏点击“View”——“Call Stack Window”,弹出“Call Stack + Locals”对话框。

image

然后在对话框中右键选择“Show Caller Code”,就会跳转到出错之前的函数处,仔细查看这部分函数被调用或者数组内存使用情况。

image

image

可以看到是因为PitchRamp.init(&PitchRamp, 1500);这句话进入的硬件错误。于是推断是PitchRamp这个结构体出了问题。

我的PitchRamp结构体定义如下:

struct ramp_t
{
  int32_t count;
  int32_t scale;
  float   out;
  void  (*init)(struct ramp_t *ramp, int32_t scale);
  float (*calc)(struct ramp_t *ramp);
};

初始化步骤如下:

struct ramp_t PitchRamp; //机器人云台pitch轴斜坡结构体

那么结论已经很明显了,结构体中有的成员变量是函数指针,但我初始化的时候没有指定函数指针指向哪个函数,所以调用函数指针的时候就出现了访问越界,产生硬件错误。

我把初始化改成下面的样子:

#define RAMP_DEFAULT \
{ \
              .count = 0, \
              .scale = 0, \
              .out = 0, \
              .init = &RampInit, \
              .calc = &RampCalc, \
            }

struct ramp_t PitchRamp = RAMP_DEFAULT; //机器人云台pitch轴斜坡结构体

问题完美解决。

MPU9250姿态解析

前段时间尝试使用陀螺仪作为机器人云台的位置环反馈值,在底盘跟随的大前提下,这样做是更符合第一视角的操作的。

我去年比赛的时候买了几个维特智能的型号为WT901的九轴,当时时间紧没有用上,结果不用不知道,一用吓一跳,陀螺仪延迟高的吓人,使用J-Scope看到的图像如下:
image

从图中可以看出延迟大约有200ms,这种延时已经不再具备方位参考值了。

我在仓库里东翻西找,找到了两个三十多块的MPU9250,相比于我买的一百来块的陀螺仪来说便宜了很多,于是我开始尝试手动解析数据。

读取原始数据

MPU9250内部其实是由6轴陀螺仪MPU6500和磁力计AK8963组成,可以测量三轴加速度,三轴角速度和三轴磁场强度,此外还可以测量温度,但这里我没有用到温度的数据。

数据解析的第一步就是读取原始数据。MPU6500支持IIC通信和SPI通信,AK8963只支持IIC通信,但可以通过MPU9250为中介使用SPI通信,只是比较麻烦,所以这里我选择了IIC通信。

IIC地址

MPU9250的IIC地址取决于AD0口的电平高低,如果AD0接GND,则地址为0x68,如果接VCC,则地址为0x69,。

这里说一下一个坑:

在读取时,注意需要人工将地址左移1位(I2C读写为左对齐,第8位要存读写标志位)

最开始我不知道这个,一直把地址写成0x68,结果死活读不到数据,后来才发现要写成0xD0。

MPU9250的其他地址可以查看它的寄存器数据手册,我这里列出了我用到的一部分寄存器:

#define MPU9250_ADDRESS 0xD0   //AD0接GND时地址为0x68,接VCC时地址为0x69
#define MPU_PWR_MGMT1_REG		0X6B	//电源管理寄存器1
#define MPU_GYRO_CFG_REG		0X1B	//陀螺仪配置寄存器
#define MPU_ACCEL_CFG_REG		0X1C	//加速度计配置寄存器
#define MPU_SAMPLE_RATE_REG		0X19	//陀螺仪采样频率分频器
#define MPU_INT_EN_REG			0X38	//中断使能寄存器
#define MPU_USER_CTRL_REG		0X6A	//用户控制寄存器
#define MPU_FIFO_EN_REG			0X23	//FIFO使能寄存器
#define MPU_INTBP_CFG_REG		0X37	//中断/旁路设置寄存器
#define MPU_DEVICE_ID_REG		0X75	//器件ID寄存器
#define MPU_PWR_MGMT2_REG		0X6C	//电源管理寄存器2
#define MPU_CFG_REG				0X1A	//配置寄存器 低通滤波器配置寄存器

#define MPU_TEMP_OUTH_REG		0X41	//温度值高8位寄存器
#define MPU_TEMP_OUTL_REG		0X42	//温度值低8位寄存器

#define MPU_ACCEL_XOUTH_REG		0X3B	//加速度值,X轴高8位寄存器
#define MPU_ACCEL_XOUTL_REG		0X3C	//加速度值,X轴低8位寄存器
#define MPU_ACCEL_YOUTH_REG		0X3D	//加速度值,Y轴高8位寄存器
#define MPU_ACCEL_YOUTL_REG		0X3E	//加速度值,Y轴低8位寄存器
#define MPU_ACCEL_ZOUTH_REG		0X3F	//加速度值,Z轴高8位寄存器
#define MPU_ACCEL_ZOUTL_REG		0X40	//加速度值,Z轴低8位寄存器

#define MPU_GYRO_XOUTH_REG		0X43	//陀螺仪值,X轴高8位寄存器
#define MPU_GYRO_XOUTL_REG		0X44	//陀螺仪值,X轴低8位寄存器
#define MPU_GYRO_YOUTH_REG		0X45	//陀螺仪值,Y轴高8位寄存器
#define MPU_GYRO_YOUTL_REG		0X46	//陀螺仪值,Y轴低8位寄存器
#define MPU_GYRO_ZOUTH_REG		0X47	//陀螺仪值,Z轴高8位寄存器
#define MPU_GYRO_ZOUTL_REG		0X48	//陀螺仪值,Z轴低8位寄存器

#define AK8963_ADDRESS 0x18		//磁力计地址0x0C
#define AK8963_CNTL1		0x0A  //磁力计读取寄存器
#define AK8963_HXL			0x03	//磁力计X轴低8位寄存器
#define AK8963_HXH			0x04	//磁力计X轴高8位寄存器
#define AK8963_HYL			0x05	//磁力计Y轴低8位寄存器
#define AK8963_HYH			0x06	//磁力计Y轴高8位寄存器
#define AK8963_HZL			0x07	//磁力计Z轴低8位寄存器
#define AK8963_HZH			0x08	//磁力计Z轴高8位寄存器

配置MPU9250

读取数据之前要对MPU9250进行一系列的配置,比如设置采样频率,设置量程等等,具体的如下:

void MPU9250_Init(void)
{
	unsigned char pdata;
	//检查设备是否准备好
	HAL_I2C_IsDeviceReady(&hi2c1, MPU9250_ADDRESS, 10, HAL_MAX_DELAY);
	//检查总线是否准备好
	HAL_I2C_GetState(&hi2c1);

	pdata=0x80; //复位MPU
	HAL_I2C_Mem_Write(&hi2c1, MPU9250_ADDRESS, MPU_PWR_MGMT1_REG, 1, &pdata, 1, HAL_MAX_DELAY);
	HAL_I2C_IsDeviceReady(&hi2c1, MPU9250_ADDRESS, 10, HAL_MAX_DELAY);
	
	HAL_Delay(500);  //复位后需要等待一段时间,等待芯片复位完成

	pdata=0x01;	//唤醒MPU
	HAL_I2C_Mem_Write(&hi2c1, MPU9250_ADDRESS, MPU_PWR_MGMT1_REG, 1, &pdata, 1, HAL_MAX_DELAY);

	pdata=3<<3; //设置量程为2000
	HAL_I2C_Mem_Write(&hi2c1, MPU9250_ADDRESS, MPU_GYRO_CFG_REG, 1, &pdata, 1, HAL_MAX_DELAY); 

	pdata=01;	//设置加速度传感器量程±4g
	HAL_I2C_Mem_Write(&hi2c1, MPU9250_ADDRESS, MPU_ACCEL_CFG_REG, 1, &pdata, 1, HAL_MAX_DELAY); 

	pdata=0; //陀螺仪采样分频设置
	HAL_I2C_Mem_Write(&hi2c1, MPU9250_ADDRESS, MPU_SAMPLE_RATE_REG, 1, &pdata, 1, HAL_MAX_DELAY); 

	pdata=0;	//关闭所有中断
	HAL_I2C_Mem_Write(&hi2c1, MPU9250_ADDRESS, MPU_INT_EN_REG, 1, &pdata, 1, HAL_MAX_DELAY); 
	
	pdata=0;	//关闭FIFO
	HAL_I2C_Mem_Write(&hi2c1, MPU9250_ADDRESS, MPU_FIFO_EN_REG, 1, &pdata, 1, HAL_MAX_DELAY); 
	
	pdata=0X02;	//设置旁路模式,直接读取AK8963磁力计数据
	HAL_I2C_Mem_Write(&hi2c1, MPU9250_ADDRESS, MPU_INTBP_CFG_REG, 1, &pdata, 1, HAL_MAX_DELAY); 
	HAL_Delay(10);	//需要一段延时让磁力计工作
	
	pdata = 4;	//设置MPU9250的数字低通滤波器
	HAL_I2C_Mem_Write(&hi2c1, MPU9250_ADDRESS, MPU_CFG_REG, 1, &pdata, 1, HAL_MAX_DELAY);

	pdata=0;	//使能陀螺仪和加速度工作
	HAL_I2C_Mem_Write(&hi2c1, MPU9250_ADDRESS, MPU_PWR_MGMT2_REG, 1, &pdata, 1, HAL_MAX_DELAY);
	
	pdata = 0x01;
	HAL_I2C_Mem_Write(&hi2c1, AK8963_ADDRESS, AK8963_CNTL1, 1, &pdata, 1, HAL_MAX_DELAY);
	HAL_Delay(10);
}

读取数据

配置好了之后就可以读取数据了,这就很简单了,直接从指定寄存器读取就可以了。这里要注意的是磁力计不能读取太频繁,AK8963本身的数据采样就有一个硬件上限,大约200Hz左右,但实测只能以一百多赫兹的频率读取。且每次读取都需要往磁力计的CNTL1寄存器写1,否则会读不到数据。

读取数据的具体步骤如下:

void GetImuData(void)
{
	uint8_t imu_data[14]={0};
	uint8_t mag_data[6] = {0};
	uint8_t pdata;
	short accx,accy,accz;
	short gyrox,gyroy,gyroz;
	short magx,magy,magz;
	
	float gyro_sensitivity = 16.384f;
	int acc_sensitivity = 8192;
	
	static short mag_count = 0;
	
	HAL_I2C_Mem_Read(&hi2c1, MPU9250_ADDRESS, MPU_ACCEL_XOUTH_REG, 1, imu_data, 14, HAL_MAX_DELAY);	//读取陀螺仪和加速度计的数据
	accx = (imu_data[0]<<8)|imu_data[1];
	accy = (imu_data[2]<<8)|imu_data[3];
	accz = (imu_data[4]<<8)|imu_data[5];
	gyrox = (imu_data[8]<<8)|imu_data[9];
	gyroy = (imu_data[10]<<8)|imu_data[11];
	gyroz = (imu_data[12]<<8)|imu_data[13];
	
	mpu9250.gyro.x = (float)(gyrox-GYROX_BIAS)/gyro_sensitivity;
	mpu9250.gyro.y = (float)(gyroy-GYROY_BIAS)/gyro_sensitivity;
	mpu9250.gyro.z = (float)(gyroz-GYROZ_BIAS)/gyro_sensitivity;
	
	mpu9250.acc.x = (float)(accx-ACCX_BIAS)/acc_sensitivity;
	mpu9250.acc.y = (float)(accy-ACCY_BIAS)/acc_sensitivity;
	mpu9250.acc.z = (float)(accz-ACCZ_BIAS)/acc_sensitivity;
	
	mag_count++;
	if(mag_count == 10)	//磁力计不能读取太频繁
	{
		HAL_I2C_Mem_Read(&hi2c1, AK8963_ADDRESS, AK8963_HXL, 1, mag_data, 6, HAL_MAX_DELAY);	//读取磁力计数据
		magx = (mag_data[0]<<8)|mag_data[1];
		magy = (mag_data[2]<<8)|mag_data[3];
		magz = (mag_data[4]<<8)|mag_data[5];
		mpu9250.mag.x = (float)magy/1000.0f;		//磁力计的坐标方位不同
		mpu9250.mag.y = (float)magx/1000.0f;
		mpu9250.mag.z = -(float)magz/1000.0f;
		pdata = 1;
		HAL_I2C_Mem_Write(&hi2c1, AK8963_ADDRESS, AK8963_CNTL1, 1, &pdata, 1, HAL_MAX_DELAY);	//为下一次读取磁力计数据做准备
		mag_count = 0;
	}
}

这个函数的执行为1kHz,也就是加速度和陀螺仪数据采样频率是1kHz,磁力计采样频率为100Hz。

姿态解析

光拿到这九轴的数据是不行的,对我们有用的是欧拉方位角,那么拿到数据之后怎么才能得到欧拉角呢?使用四元数就能解决这个问题。

四元数

image

说实话四元数真不太容易理解,我现在也还是一脸懵逼,但不用慌,我们只需要掌握四元数与欧拉角之间的转换就行了,公式如下:

image

image

对四元数本身感兴趣的可以看这个视频(地址),看过之后我还是不太懂。。。

那么怎么根据我们现有的九轴数据求解四元数呢?

其实都用不到九轴,我们只需要用到陀螺仪的数据,也就是角速度就可以求解到四元数,计算公式如下:

image

(这里利用一阶龙格库塔公式进行积分求解四元数)

也就是说姿态解析中陀螺仪才是主角,我们通过陀螺仪积分得到角度,但是时间久了陀螺仪会产生误差,也就是漂移,时间短还可以忍受,时间一长误差就很大了,所以需要加速度和磁力计来纠正陀螺仪的数据。

互补滤波

加速度计主要测量的是地球的重力加速度,根据重力加速度在三个轴方向的分量可以推算出当前物体的姿态。磁力计也是相同的道理。

加速度计可以纠正pitch轴和roll轴的角度误差,因为航向角不管指向何方,加速度的读数都是一样的,所以需要磁力计来纠正yaw轴的角度误差。

那么怎么纠正呢?我这里使用的是互补滤波。

因为我们要求的是物体坐标相对于地面坐标的三轴偏转角度,所以要把加速度的各个分量(在物体的坐标系下)转换为在地面坐标的分量。进而得到物体的方位,因为陀螺仪本身有误差,所以由陀螺仪计算出来的方位肯定与加速度计算出来的方位有误差,这个误差用向量的叉乘表示,将这个误差叠加在陀螺仪计算出来的结果上就可以抵消误差,进而达到纠正角度的作用。磁力计同上。

这里使用互补滤波来进行纠正。公式如下:

image

这里构造了一个比例-积分控制器,有一个比例系数和一个积分系数。比例系数越大,表示越信任加速度计和磁力计,积分系数越大,表示越信任陀螺仪。

image

把误差叠加上去就行了。

上述步骤不断循环,最后使误差越来越小,角度越来越接近真实值。

代码参考

最后附上我的姿态解析代码:

void imuUpdate(struct Axisf acc, struct Axisf gyro, struct Axisf mag)
{
	float q0q0 = q0 * q0;
	float q1q1 = q1 * q1;
	float q2q2 = q2 * q2;
	float q3q3 = q3 * q3;

	float q0q1 = q0 * q1;
	float q0q2 = q0 * q2;
	float q0q3 = q0 * q3;
	float q1q2 = q1 * q2;
	float q1q3 = q1 * q3;
	float q2q3 = q2 * q3;
	
	float normalise;
	float ex, ey, ez;
	float halfT;
	float hx, hy, hz, bx, bz;
	float vx, vy, vz, wx, wy, wz;
	
	now_update = HAL_GetTick(); //单位ms
	halfT = ((float)(now_update - last_update) / 2000.0f);
	last_update = now_update;
	
	gyro.x *= DEG2RAD;	/*度转弧度*/
	gyro.y *= DEG2RAD;
	gyro.z *= DEG2RAD;
	
	/* 对加速度计数据进行归一化处理 */
	if(acc.x != 0 || acc.y != 0 || acc.z != 0)
	{
		normalise = sqrt(acc.x * acc.x + acc.y * acc.y + acc.z * acc.z);
		acc.x /= normalise;
		acc.y /= normalise;
		acc.z /= normalise;
	}
	
	/* 对磁力计数据进行归一化处理 */
	if(mag.x != 0 || mag.y != 0 || mag.z != 0)
	{
		normalise = sqrt(mag.x * mag.x + mag.y * mag.y + mag.z * mag.z);
		mag.x /= normalise;
		mag.y /= normalise;
		mag.z /= normalise;
	}
	
	/* 计算磁力计投影到物体坐标上的各个分量 */
	hx = 2.0f*mag.x*(0.5f - q2q2 - q3q3) + 2.0f*mag.y*(q1q2 - q0q3) + 2.0f*mag.z*(q1q3 + q0q2);
	hy = 2.0f*mag.x*(q1q2 + q0q3) + 2.0f*mag.y*(0.5f - q1q1 - q3q3) + 2.0f*mag.z*(q2q3 - q0q1);
	hz = 2.0f*mag.x*(q1q3 - q0q2) + 2.0f*mag.y*(q2q3 + q0q1) + 2.0f*mag.z*(0.5f - q1q1 - q2q2);         
	bx = sqrt((hx*hx) + (hy*hy));
	bz = hz; 
	
	/* 计算加速度计投影到物体坐标上的各个分量 */
	vx = 2.0f*(q1q3 - q0q2);
	vy = 2.0f*(q0q1 + q2q3);
	vz = q0q0 - q1q1 - q2q2 + q3q3;
	
	/* 处理过后的磁力计新分量 */
	wx = 2.0f*bx*(0.5f - q2q2 - q3q3) + 2.0f*bz*(q1q3 - q0q2);
	wy = 2.0f*bx*(q1q2 - q0q3) + 2.0f*bz*(q0q1 + q2q3);
	wz = 2.0f*bx*(q0q2 + q1q3) + 2.0f*bz*(0.5f - q1q1 - q2q2); 
	
	/* 叉积误差累计,用以修正陀螺仪数据 */
	ex = (acc.y*vz - acc.z*vy) + (mag.y*wz - mag.z*wy);
	ey = (acc.z*vx - acc.x*vz) + (mag.z*wx - mag.x*wz);
	ez = (acc.x*vy - acc.y*vx) + (mag.x*wy - mag.y*wx);
	
	/* 互补滤波 PI */
	exInt += ex * Ki * halfT;
	eyInt += ey * Ki * halfT;	
	ezInt += ez * Ki * halfT;
	gyro.x += Kp*ex + exInt;
	gyro.y += Kp*ey + eyInt;
	gyro.z += Kp*ez + ezInt;
	
	/* 使用一阶龙格库塔更新四元数 */
	q0 += (-q1 * gyro.x - q2 * gyro.y - q3 * gyro.z) * halfT;
	q1 += ( q0 * gyro.x + q2 * gyro.z - q3 * gyro.y) * halfT;
	q2 += ( q0 * gyro.y - q1 * gyro.z + q3 * gyro.x) * halfT;
	q3 += ( q0 * gyro.z + q1 * gyro.y - q2 * gyro.x) * halfT;
	
	/* 对四元数进行归一化处理 */
	normalise = sqrt(q0 * q0 + q1 * q1 + q2 * q2 + q3 * q3);
	q0 /= normalise;
	q1 /= normalise;
	q2 /= normalise;
	q3 /= normalise;
	
	/* 由四元数求解欧拉角 */
	mpu9250.attitude.x = -asinf(-2*q1*q3 + 2*q0*q2) * RAD2DEG;	//pitch
	mpu9250.attitude.y = atan2f(2*q2*q3 + 2*q0*q1, -2*q1*q1 - 2*q2*q2 + 1) * RAD2DEG;	//roll
	mpu9250.attitude.z = atan2f(2*q1*q2 + 2*q0*q3, -2*q2*q2 - 2*q3*q3 + 1) * RAD2DEG;	//yaw
	
	yaw = mpu9250.attitude.z*100;		//用于J-Scope读取
	pitch = mpu9250.attitude.x*100;
	roll = mpu9250.attitude.y*100;
}

参考

  1. 【教程】四旋翼飞行器姿态解算算法入门学习-Rick Grimes
  2. [UVA]Pixhawk之姿态解算篇(2)

keil选择性编译那些事儿

今天遇到个非常奇葩的问题,我首先定义了四个全局变量m3508_1m3508_2m3508_3m3508_4

struct CAN_Motor
{
    int fdbPosition;        //电机的编码器反馈值
    int last_fdbPosition;   //电机上次的编码器反馈值
    int bias_position;      //机器人初始状态电机位置环设定值
    int fdbSpeed;           //电机反馈的转速/rpm
    int round;              //电机转过的圈数
    int real_position;      //过零处理后的电机转子位置
};

struct CAN_Motor m3508_1 = DEFAULT_MOTOR;    //底盘四个电机的数据存储结构体
struct CAN_Motor m3508_2 = DEFAULT_MOTOR;
struct CAN_Motor m3508_3 = DEFAULT_MOTOR;
struct CAN_Motor m3508_4 = DEFAULT_MOTOR;

初始化的时候也相应地初始化了结构体里面的成员变量。

#define DEFAULT_MOTOR \
{0,0,0,0,0,0}

程序编译下来没有报错,但是下载进单片机后,把变量加入watch窗口查看,但是却是如下显示:
image

这明明是局部变量才会出现的提示啊!!

后面经过我自己的自闭后发现了一些规律:

首先我尝试写了一个函数,象征性地改变一下其中一个结构体的参数,然后在main函数中调用它:

void test()
{
  m3508_1.fdbPosition = 1;
  m3508_2.fdbPosition = 1;
}

然后我再进入调试,奇迹就发生了!
image

四个结构体都可以查看了,而且我改变过的m3508_1m3508_2两个结构体的数据均已经变为正常了,而剩下的两个未使用的结构体虽然能查看数据,但是数据全是错的。

到这里我基本就看出了一些端倪,然后我把m3508_3也加了进去:

void test()
{
  m3508_1.fdbPosition = 1;
  m3508_2.fdbPosition = 1;
  m3508_3.fdbPosition = 1;
}

然后进入调试。
image

果然!m3508_3结构体也正常了!

从上面的实验可以看出,keil在编译工程文件的时候,会把没有使用到的变量、变量类型和函数都删除,如果变量初始化后但是没有使用,那么它的值就会是一个很奇怪的值*(偶尔会正常)*

最后,我把main函数里面的test();语句注释掉,最后调试结果也正如上面所分析的,这四个结构体都是不能查看的,因为keil压根就没有编译这些变量。

机器学习-监督学习&无监督学习

机器学习分为两大类:监督学习无监督学习

监督学习

假如这里有一些房价的数据。你把这些数据画出来,看起来是这个样子:横轴表示房子的面积,单位是平方英尺,纵轴表示房价,单位是千美元。那基于这组数据,假如你有一个朋友,他有一套750平方英尺房子,现在他希望把房子卖掉,他想知道这房子能卖多少钱。

image

我们应用学习算法,可以在这组数据中画一条直线,或者换句话说,拟合一条直线(上图中品红色线),根据这条线我们可以推测出,这套房子可能卖150,000$。

当然这不是唯一的算法。可能还有更好的,比如我们不用直线拟合这些数据,用二次方程去拟合可能效果会更好。根据二次方程的曲线(上图中蓝色线),我们可以从这个点推测出,这套房子能卖接近200,000$。

可以看出,监督学习指的就是我们给学习算法一个数据集。这个数据集由“正确答案”组成。在房价的例子中,我们给了一系列房子的数据,我们给定数据集中每个样本的正确价格,即它们实际的售价然后运用学习算法,算出更多的正确答案。比如你朋友那个新房子的价格。用术语来讲,这叫做回归问题。我们试着推测出一个连续值的结果,即房子的价格。

回归这个词的意思是,我们在试着推测出这一系列连续值属性。

监督学习基本**是,我们数据集中的每个样本都有相应的“正确答案”。再根据这些样本作出预测,就像房子和肿瘤的例子中做的那样。我们还介绍了回归问题,即通过回归来推出一个连续的输出,之后我们介绍了分类问题,其目标是推出一组离散的结果。

学习算法可以处理两种问题:回归问题分类问题。假设有一个公司,想开发学习算法解决下面两个问题:

  1. 你有一大批同样的货物,想象一下,你有上千件一模一样的货物等待出售,这时你想预测接下来的三个月能卖多少件?

  2. 你有许多客户,这时你想写一个软件来检验每一个用户的账户。对于每一个账户,你要判断它们是否曾经被盗过?

问题一是一个回归问题,因为你知道,如果我有数千件货物,我会把它看成一个实数,一个连续的值。因此卖出的物品数,也是一个连续的值。

问题二是一个分类问题,因为我会把预测的值,用 0 来表示账户未被盗,用 1 表示账户曾经被盗过。所以我们根据账号是否被盗过,把它们定为0 或 1,然后用算法推测一个账号是 0 还是 1,因为只有少数的离散值,所以我把它归为分类问题。

以上就是监督学习的内容。

无监督学习

在无监督学习中,不同于监督学习的数据的样子,无监督学习中没有任何的标签。所以我们已知数据集,却不知如何处理,也未告知每个数据点是什么。别的都不知道,就是一个数据集。针对数据集,无监督学习就能判断出数据有两个不同的聚集簇。这是一个,那是另一个,二者不同。是的,无监督学习算法可能会把这些数据分成两个不同的簇。所以叫做聚类算法

image

聚类应用的一个例子就是在谷歌新闻中。谷歌新闻每天都在收集非常多,非常多的网络的新闻内容。它再将这些新闻分组,组成有关联的新闻。所以谷歌新闻做的就是搜索非常多的新闻事件,自动地把它们聚类到一起。所以,这些新闻事件全是同一主题的,所以显示到一起。

所以这个就是无监督学习,因为我们没有提前告知算法一些信息,比如,这是第一类的人,那些是第二类的人,还有第三类,等等。我们只是说,是的,这是有一堆数据。我不知道数据里面有什么。我不知道谁是什么类型。我甚至不知道人们有哪些不同的类型,这些类型又是什么。但你能自动地找到数据中的结构吗?就是说你要自动地聚类那些个体到各个类,我没法提前知道哪些是哪些。因为我们没有给算法正确答案来回应数据集中的数据,所以这就是无监督学习。

关于我

个人概况

作为一个程序员,用简单的代码来描述自己,或许再恰当不过了。如果你还是习惯常规的文字描述,请点击这里

(function(nick, createAt) {
 
  const now = (new Date).getFullYear();
 
  class Person {
    constructor(params) {
      Person.iterationHelper(params, (prop) => this[prop] = params[prop]);
    }
 
    set dreamCode(val) {
      this.Dream = String.fromCharCode.apply(null, val);
      delete this.dreamCode;
    }
 
    static iterationHelper(data, fn) {
      Object.keys(data).forEach(fn);
    }
 
    static introduce(content) {
      console.log(content);
    }
  }
 
  const dreamCode = [
    0x43, 0x6f, 0x64, 0x65, 0x20,
    0x74, 0x68, 0x65, 0x20,
    0x77, 0x6f, 0x72,
    0x6c, 0x64,
    0x2e,
  ];
  const name = '谢胜';
  const sex = '男';
  const age = now - createAt;
  const school = '哈尔滨工业大学(深圳)';
  let tags = ['嵌入式', '深度学习', '前端', '计算机视觉'];
  let hobby = ['3D绘画', '追番', 'RoboMaster'];
 
  let me = new Person({name, sex, age, nick, school, dreamCode, tags, hobby});
 
  with (Person) iterationHelper(me, (n) => introduce(`${n.replace(/^\w/, c => c.toUpperCase())}:\t${me[n]}`));
 
})('biguncle', 0x07ce);

一眼看不出运行结果的话,可以放控制台里运行 :D

简单概述

我是谢胜,目前就读于哈尔滨工业大学(深圳)控制科学与工程专业,研一。

由于高考没考好被调剂到材料专业,不甘于此,在课外时间自己专研嵌入式方向的知识,参加全国大学生机器人大赛RoboMaster机甲大师赛。除此之外还喜欢探索一些小工具,尝试新的技术。

研究生顺利跨保控制科学与工程专业,平日里不定期产生一些有趣的小灵感,追求用代码拯救世界。目前研究领域为计算机视觉、深度学习。

平日里偶尔画画,日常追番,宅男一枚。

我的联系方式:

简单随笔

搭建这个网站原本只是想记录自己的学习过程和奋斗经历,结果在很多时候还帮了大忙。我这人记性很差,以前写代码总是忘记一些关键性的东西,现在好了,可以很方便地查找到对应的知识点。

平日里喜欢听歌写代码,未来可能投身互联网事业,也可能去研究机器人设计与控制,偶尔也想晒晒太阳发发呆。有时也是个文字控,喜欢听人讲故事。

一路磕磕绊绊,也总算是入了程序猿的大门,也遇到了患难与共的朋友,悉心指点的师兄,在此谢过。

未来可期,梦想可贵,不论如何,我都会坚定地走下去。

希望能保住头发。

步兵底盘小陀螺的实现

我第一次看见小陀螺的时候是在去年比赛的佛山分区赛,当时我的比赛已经彻底结束了,在备场区看比赛直播,看见了有个战队的步兵可以360度旋转,还可以同时实现底盘的前后左右移动,当时死活想不通是怎么实现的。

昨天逛空间的时候再次看见了这种小陀螺,跑的确实欢快,心里非常羡慕,于是今天下午决心要实现小陀螺。

但没想到稍微一想就有些思路了,其实就是一个运动的分解与合成,在此记录一下。

首先看看之前的步兵车底盘的基本运动计算方程:

m3508_1.speed_pid.ref = infantry.chassis.FBSpeed + infantry.chassis.LRSpeed + infantry.chassis.RotateAngle;
m3508_2.speed_pid.ref = -infantry.chassis.FBSpeed + infantry.chassis.LRSpeed + infantry.chassis.RotateAngle;
m3508_3.speed_pid.ref = -infantry.chassis.FBSpeed - infantry.chassis.LRSpeed + infantry.chassis.RotateAngle;
m3508_4.speed_pid.ref = infantry.chassis.FBSpeed - infantry.chassis.LRSpeed + infantry.chassis.RotateAngle;

从上面的代码可以看出,整个底盘是由前后运动、左右运动和旋转运动三种运动合成的,就像声波传输一样,多种波可以叠加在一起并互不影响。

明白了这一点就很简单了,一张图就能讲解清楚。
image

从上图可以看出,只要获取到了当前云台与底盘的夹角是遥控器给的指令,是相对于底盘正方向的前后左右速度。

它们之间存在以下的关系:

那么这个角度怎么获取呢?我使用了步兵车的yaw轴电机获取当前的角度差。代码如下:

angle = yaw_motor.fdbPosition - infantry.gimbal.YawBiasAngle;
if(angle < 0)
{
  angle += 8192;
}
angle = angle * 0.00076f;

这里我遇到一个坑,我在菜鸟教程的C语言在线编译器上执行三角函数,发现三角函数是以角度值计算的,结果电机各种抖动,后来才发现在keil中三角函数是以弧度值计算的。

所以angle的转换系数为

最后附上我的代码:

float angle;
int FBSpeed;
int LRSpeed;

FBSpeed = (Remote.rc.ch1 - CH0_BIAS);
LRSpeed = (Remote.rc.ch0 - CH1_BIAS);
infantry.chassis.RotateAngle = 1800;

angle = yaw_motor.fdbPosition - infantry.gimbal.YawBiasAngle;
if(angle < 0)
{
  angle += 8192;
}
angle = angle * 0.00076f;

infantry.chassis.FBSpeed = (float)FBSpeed * cos(angle) + (float)LRSpeed * sin(angle);
infantry.chassis.LRSpeed = (float)FBSpeed * -sin(angle) + (float)LRSpeed * cos(angle);

m3508_1.speed_pid.ref = infantry.chassis.FBSpeed + infantry.chassis.LRSpeed + infantry.chassis.RotateAngle;
m3508_2.speed_pid.ref = -infantry.chassis.FBSpeed + infantry.chassis.LRSpeed + infantry.chassis.RotateAngle;
m3508_3.speed_pid.ref = -infantry.chassis.FBSpeed - infantry.chassis.LRSpeed + infantry.chassis.RotateAngle;
m3508_4.speed_pid.ref = infantry.chassis.FBSpeed - infantry.chassis.LRSpeed + infantry.chassis.RotateAngle;

实际的步兵车底盘小陀螺运动效果展示
image

卡尔曼滤波

卡尔曼滤波的学习源于机器人自瞄的需求,奈何自己太菜,好几次打开教程想看最后都因看不太懂而放弃,今日再看,拿出稿纸一步一步跟着推导,到最后推导出卡尔曼滤波公式的时候,竟然有点错愕,仿佛还没有看见卡尔曼就已经走进它的世界一样。

我也不多写什么总结,直接列出我看的三个教程,按我学习的时间先后顺序排列,真的是很好的教程,浅显易懂!

  1. Artificial Intelligence for Robotics - Udacity
  2. 如何通俗并尽可能详细解释卡尔曼滤波?
  3. 卡尔曼滤波器及其在云台控制中的应用

最后附上两张图,来源见水印。
image

image

串口空闲中断接收遥控器数据

问题描述

大疆给RoboMaster比赛专门做了一版遥控器固件,通信协议很简单,每14ms发送一帧18字节长度的数据,其中没有任何用于检验数据是否接收正确的信息。

虽然14ms的间隔很长,如果不是运气太差,是不会恰好在数据接收途中发生中断什么的,但可能性还是有的,更何况实际比赛的时候遥控器非常多,比赛赛场有,备场有,可能接收到杂其他遥控器发过来杂乱数据,如果程序没有一定的容错性,遥控器数据可能会出错,车子会莫名其妙地疯跑。

另外在日常调试的时候,往往keil中进入调试,点击运行后,发现遥控器数据错乱,此时要软件复位才行,这也是因为上述原因。在进入调试的时候,遥控器接收机正常通电工作,反馈数据,在点击运行的时候恰好是接收机在反馈一帧数据的时候,导致单片机直接从数据帧的中途接收,使得之后的数据全部错位,数据全乱了。

这种问题还出现在,先开遥控器后给机器人上电的时候,有时候反应为机器人无法遥控,有时候反应为开机就疯,这都是遥控器的数据出错。

解决办法

之前研究裁判系统接收数据(可以点这里回顾)的时候,当时遇到了串口空闲中断,这次又派上用场了。

串口空闲中断

开启空闲中断后,单片机会在接收完一帧数据后触发接收中断,与设置的接收字节无关(当然设置的接收字节数要大于一帧可能的最大字节数)。空闲中断非常适合于接收不定长数据,既然是不定长,当然就无法实现接收的字节数,一个一个字节接收又显得太麻烦,空闲中断就完美地解决了这个问题。

遥控器的数据是定长的,我们可以用空闲中断来构造一个校验位,只有接收到的一帧数据是18位的才认为遥控器的数据正确,

实现代码

STM32CubeMX里面的串口配置很简单,波特率100Kbps,8位,停止位1位,偶校验,无硬件流,开启DMA和global interrupt,点击生成代码,完事。

打开新建的工程,首先打开串口空闲中断:

  MX_USART1_UART_Init();
  /* USER CODE BEGIN 2 */
  __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
  HAL_UART_Receive_DMA(&huart1, rc_data, 18u);

然后自定义一个空闲中断回调函数:

void UART_IDLE_Callback(UART_HandleTypeDef *huart)
{
  if(__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE))     //判断一帧数据是否接收完毕
  {
		__HAL_UART_CLEAR_IDLEFLAG(huart);     //清空IDLE标志位
		(void)USART1->SR;      //清空SR寄存器
		(void)USART1->DR;     //清空DR寄存器
		__HAL_DMA_CLEAR_FLAG(huart, DMA_FLAG_TCIF2_6);    //清空DMA传输完成标志位
		HAL_UART_DMAStop(huart);
		if(__HAL_DMA_GET_COUNTER(huart->hdmarx) == 0)     //如果接收的长度是18
		{
			RemoteReceiveHandle();
		}
		HAL_UART_Receive_DMA(huart,rc_data,18u);		//再次使能接收,NDTR重载
	}
}

串口huart1中有一个寄存器NDTR存储了剩余的可用缓存字节,__HAL_DMA_GET_COUNTER(huart->hdmarx)返回的就是这个寄存器的值,所以当它为0时,表示这一帧接收到了18个字节。

上述回调函数添加在stm32f4xx_it.c中:

/**
  * @brief This function handles USART1 global interrupt.
  */
void USART1_IRQHandler(void)
{
  /* USER CODE BEGIN USART1_IRQn 0 */
	UART_IDLE_Callback(&huart1);
  /* USER CODE END USART1_IRQn 0 */
  //HAL_UART_IRQHandler(&huart1);
  /* USER CODE BEGIN USART1_IRQn 1 */

  /* USER CODE END USART1_IRQn 1 */
}

记得一定要原来的HAL_UART_IRQHandler()注释掉,不然只能接收到遥控器数据的第一个字节。

Electron学习(一)

electron基于JavaScript开发,给前端开发者带来了福音,近些年来JavaScript的优势越来越大了。

electron依赖node.js,可以用于开发PC端的桌面应用,而且非常容易跨平台,渐渐得称为桌面应用开发的主流。

开发环境搭建

首先是安装node.js,下载链接点这里

然后搭建electron开发环境,有两种方式。一种是全局安装:

npm install electron -g

另一种是局部安装,也就是每一个项目的electron是单独的,互不干扰:

npm install --save-dev electron

这里我选择的是局部安装的方式。

准备完毕之后在项目的根目录下执行

npm init

初始化package.json文件。然后就可以开始写代码了。

最简单的electron项目

直接贴出代码:

index.js:

const { app, BrowserWindow } = require('electron')

// 保持对window对象的全局引用,如果不这么做的话,当JavaScript对象被
// 垃圾回收的时候,window对象将会自动的关闭
let win

function createWindow () {
  // 创建浏览器窗口。
  win = new BrowserWindow({ width: 800, height: 600 })

  // 然后加载应用的 index.html。
  win.loadFile('index.html')

  // 打开开发者工具
  win.webContents.openDevTools()

  // 当 window 被关闭,这个事件会被触发。
  win.on('closed', () => {
    // 取消引用 window 对象,如果你的应用支持多窗口的话,
    // 通常会把多个 window 对象存放在一个数组里面,
    // 与此同时,你应该删除相应的元素。
    win = null
  })
}

// Electron 会在初始化后并准备
// 创建浏览器窗口时,调用这个函数。
// 部分 API 在 ready 事件触发后才能使用。
app.on('ready', createWindow)

// 当全部窗口关闭时退出。
app.on('window-all-closed', () => {
  // 在 macOS 上,除非用户用 Cmd + Q 确定地退出,
  // 否则绝大部分应用及其菜单栏会保持激活。
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  // 在macOS上,当单击dock图标并且没有其他窗口打开时,
  // 通常在应用程序中重新创建一个窗口。
  if (win === null) {
    createWindow()
  }
})

// 在这个文件中,你可以续写应用剩下主进程代码。
// 也可以拆分成几个文件,然后用 require 导入。

然后执行

npm start

启动这个electron项目。

响应事件

编写 GUI 应用要做的最重要的事情就是响应事件,如单击按钮事件、窗口关闭事件等。对于 Electron 应用来说,事件分为如下两类:

  • 原生事件
  • Web事件

由于 Electron 在创建窗口时需要依赖本地 API,因而有一部分事件属于本地 API 原生的事件。但 Electron 主要使用 Web 技术开发应用,因而用的最多的还是 Web 事件,这些事件的使用方法与传统的 Web 技术完全相同。

Electron 的原生事件有很多,比如窗口关闭事件 close、Electron 初始化完成后的事件 ready(这个在前面已经讲过了)、当全部窗口关闭后触发的事件 window-all-closed(通常在这个事件中完成最后的资源释放工作)、Electron 应用激活后触发的事件(activate,在 macOS 上,当单击 dock 图标并且没有其他窗口打开时,通常在应用程序中重新创建一个窗口,因此,一般在该事件中判断窗口对象是否为 null,如果是,则再次创建窗口)。window-all-closed 事件先于 closed 触发。

electron应用的特性

  • 支持创建多窗口应用,而且每个窗口都有自己独立的 JavaScript 上下文;
  • 可以通过屏幕 API 整合桌面操作系统的特性,也就是说,使用 Web 技术编写的桌面应用的效果与使用本地编程语言(如 C++)开发的桌面应用的效果类似;
  • 支持获取计算机电源状态;
  • 支持阻止操作系统进入省电模式(对于演示文稿类应用非常有用);
  • 支持创建托盘应用;
  • 支持创建菜单和菜单项;
  • 支持为应用增加全局键盘快捷键;
  • 支持通过应用更新来自动更新应用代码,也就是热更新技术;
  • 支持汇报程序崩溃;
  • 支持自定义 Dock 菜单项;
  • 支持操作系统通知;
  • 支持为应用创建启动安装器。

打开对话框

Electron 中提供了一个 Dialog 对象,通过该对象的若干个方法,可以显示不同类型的对话框,如打开文件对话框、保存文件对话框、信息对话框、错误对话框等。

获取 Dialog 对象的代码如下:

const remote = require('electron').remote;
const dialog = remote.dialog;

或者使用下面的代码:

const {dialog} = require('electron')

打开对话框通过 showOpenDialog 方法显示,该方法的原型如下:

dialog.showOpenDialog([browserWindow, ]options[, callback])

其中 browserWindow 和 callback 都是可选的,browserWindow 参数允许该对话框将自身附加到父窗口,作为父窗口的模态对话框。callback 是回调函数,用于返回打开文件或目录后的返回值(文件或目录的绝对路径),如果不指定 callback 参数,通过 showOpenDialog 方法返回打开的文件或目录的绝对路径。

options 是必选参数,该参数是一个对象,包含了一些用于设置打开对话框的属性,主要属性的功能及含义如下表所示:

属性 数据类型 功能 可选 / 必选
title String 对话框标题 可选
defaultPath String 默认路径 可选
buttonLabel String 按钮文本,当为空时,使用默认按钮文本 可选
filters Array 过滤器,用于过滤指定类型的文件 可选
properties Array 包含对话框的功能,如打开文件、打开目录、多选等 必选
message String 将标题显示在打开对话框顶端 可选

打开文件对话框

显示打开文件对话框,只需要将 properties 属性值设为 openFile 即可,代码如下:

function onClick_OpenFile() {
    const label = document.getElementById('label');
    //  显示打开文件对话框,并将选择的文件显示在页面上
    label.innerText= dialog.showOpenDialog({properties: ['openFile']})
}

定制对话框

通过设置 options 对象中的一些属性,可以设置打开对话框的标题、按钮文本和默认目录,代码如下:

function onClick_CustomOpenFile() {
    const label = document.getElementById('label');
    var options = {};
    //  设置 Windows 版打开对话框的标题
    options.title = '打开文件';
    //  设置 Mac OS X 版本打开对话框的标题
    options.message = '打开我的文件';
    //  设置按钮的文本
    options.buttonLabel = '选择';
    // 设置打开文件对话框的默认路径(当前目录)
    options.defaultPath = '.';
    options.properties = ['openFile'];
    label.innerText= dialog.showOpenDialog(options)
}

选择指定类型的文件

如果需要打开指定类型的文件,需要设置 filters 属性,例如,下面的代码为打开文件对话框指定了图像文件、视频文件、音频文件等文件类型,文件类型是通过文件扩展名指定的:

function onClick_FileType(){
    const label = document.getElementById('label');
    var options = {};
    options.title = '打开文件';
    options.buttonLabel = '选择';
    options.defaultPath = '.';
    options.properties = ['openFile'];
    //  指定特定的文件类型
    options.filters = [
        {name: '图像文件', extensions: ['jpg', 'png', 'gif']},
        {name: '视频文件', extensions: ['mkv', 'avi', 'mp4']},
        {name: '音频文件', extensions: ['mp3','wav']},
        {name: '所有文件', extensions: ['*']}
    ]
    label.innerText= dialog.showOpenDialog(options)
}

打开和创建目录

如果需要打开目录,而不是文件,properties 属性值需要包含 openDirectory。在 Windows 下,鼠标右键单击目录的空白处,就会弹出一个菜单,通过该菜单可以完成很多工作,如在当前目录创建一个子目录。但在 Mac OS X 下,没有这个弹出菜单,所以需要使用 createDirectoryr 属性在对话框左下角添加一个用于创建目录的按钮才能在当前目录中创建子目录,代码如下:

function onClick_OpenAndCreateDirectory() {
    const label = document.getElementById('label');
    var options = {};
    options.title = '打开目录';
    //  createDirectory仅用于Mac OS 系统
    options.properties = ['openDirectory','createDirectory'];
    label.innerText= dialog.showOpenDialog(options)
}

选择多个文件和目录

选择多个文件和目录,需要为 properties 属性指定 'multiSelections' 值。

不过 Mac OS X 和 Windows 的表现有些不太一样。如果要想同时选择多个文件和目录,在 Mac OS X 下需要同时为 properties 属性指定 'openFile' 和 'openDirectory',而在 Windows 下,只需要为 properties 属性指定 'openFile' 即可。

如果在 Windows 下指定了 'openDirectory',不管是否指定 'openFile',都只能选择目录,而不能显示文件(对话框中根本就不会显示文件),所以如果要让 Mac OS X 和 Windows 都能同时选择文件和目录,需要单独考虑每个操作系统,代码如下:

function onClick_MultiSelection() {
    const label = document.getElementById('label');
    var options = {};
    options.title = '选择多个文件和目录';
    options.message = '选择多个文件和目录';
    //  添加多选属性和打开文件属性
    options.properties = ['openFile','multiSelections'];
    //  如果是Mac OS X,添加打开目录属性
    if (process.platform === 'darwin') {
        options.properties.push('openDirectory');
    }
    label.innerText= dialog.showOpenDialog(options)
}

通过回调函数返回选择结果

showOpenDialog 方法的最后一个参数用于指定一个回调函数,如果指定了回调函数,showOpenDialog 方法就会通过回调函数的第 1 个参数返回选择的文件和目录,该回调函数的第 1 个参数是字符串数组类型的值。

function onClick_Callback() {
    const label = document.getElementById('label');
    var options = {};
    options.title = '选择多个文件和目录';
    options.message = '选择多个文件和目录';

    options.properties = ['openFile','multiSelections'];
    if (process.platform === 'darwin') {
        options.properties.push('openDirectory');
    }
   //  指定回调函数,在回调函数中通过循环获取选择的多个文件和目录
    dialog.showOpenDialog(options,(filePaths) =>{
        for(var i = 0; i < filePaths.length;i++) {
            label.innerText += filePaths[i] + '\r\n';
        }

    });
}

保存对话框

使用 showSaveDialog 方法可以显示保存对话框,保存对话框与打开对话框类似,需要自己输入要保存的用户名,当然,也可以选择已经存储的文件名,不过这样一来就会覆盖这个文件。

这里要强调一点,保存对话框只是提供了要保存的文件名,至于是否保存文件、以何种文件格式保存,保存对话框并不负责,需要另外编写代码解决。

showSaveDialog 方法与 showOpenDialog 方法的参数类似,下面的代码演示了如何用 showOpenDialog 方法来显示保存对话框返回的文件名。

function onClick_Save() {
    const label = document.getElementById('label');
    var options = {};
    options.title = '保存文件';
    options.buttonLabel = '保存';
    options.defaultPath = '.';
    //Only Mac OS X,输入文件名文本框左侧的标签文本
    options.nameFieldLabel = '请输入要保存的文件名';
    //是否显示标记文本框,默认值为True
    //options.showsTagField = false;
    //设置要过滤的图像类型  
    options.filters = [
        {name: '图像文件', extensions: ['jpg', 'png', 'gif']},
        {name: '视频文件', extensions: ['mkv', 'avi', 'mp4']},
        {name: '音频文件', extensions: ['mp3','wav']},
        {name: '所有文件', extensions: ['*']}
    ]
    //显示保存文件对话框,并将返回的文件名显示页面上
    label.innerText= dialog.showSaveDialog(options)
}

showSaveDialog 方法同样也可以指定回调函数,下面的代码通过回调函数得到保存对话框返回的文件名。

function onClick_SaveCallback() {
    const label = document.getElementById('label');
    var options = {};
    options.title = '保存文件';
    options.buttonLabel = '保存';
    options.defaultPath = '.';
    //  Only Mac OS X
    options.nameFieldLabel = '请输入要保存的文件名';
    // 
    options.showsTagField = false;
    options.filters = [
        {name: '图像文件', extensions: ['jpg', 'png', 'gif']},
        {name: '视频文件', extensions: ['mkv', 'avi', 'mp4']},
        {name: '音频文件', extensions: ['mp3','wav']},
        {name: '所有文件', extensions: ['*']}
    ]
    dialog.showSaveDialog(options,(filename) => {
        label.innerText = filename;
    })
}

显示对话框消息

通过 showMessageBox 方法,可以显示各种类型的对话框。该方法的参数与前面介绍的方法类似。

最简单的对话框

最简单的消息对话框,需要设置对话框标题和显示的消息。标题使用 title 属性设置,消息使用 message 属性设置,实现代码如下。

function onClick_MessageBox() {
    const label = document.getElementById('label');
    var options = {};
    options.title = '信息';
    options.message = '这是一个信息提示框';
    //  设置对话框的图标
    // options.icon = '../../../images/note.png';  
    label.innerText= dialog.showMessageBox(options)
}

对话框类型

对话框有多种类型,如信息对话框、错误对话框、询问对话框和警告对话框,这些对话框的类型通过 type 属性设置,其值如下

  • 默认对话框:none
  • 信息对话框:info
  • 错误对话框:error
  • 询问对话框:question
  • 警告对话框:warning
function onClick_MessageBox() {
    var options = {};
    options.title = '警告';
    options.message = '这是一个警告提示框';
   // 设置对话框类型
    options.type = 'warning';
    dialog.showMessageBox(options)
}

对话框按钮

通过 buttons 属性可以设置对话框的按钮,默认只显示一个按钮,buttons 属性是字符串数组类型,每一个数组元素代表一个按钮的文本:

function onClick_MessageBox() {
    var options = {};
    options.title = '警告';
    options.message = '这是一个警告提示框';
    options.icon = '../../../images//note.png';
    options.type = 'warning';
    options.buttons = ['按钮1','按钮2','按钮3','按钮4','按钮5']
    //  获取单击按钮的索引,并将索引输出到控制台
    dialog.showMessageBox(options,(response) => {
        console.log('当前被单击的按钮索引是' + response);
    })
}

错误提示对话框

通过 showErrorBox 方法可以非常容易地显示错误对话框,该方法只有两个参数,第一个参数表示标题,第二个参数表示内容,下面的代码显示了错误对话框:

function onClick_ErrorBox() {
    var options = {};
    options.title = '错误';
    options.content = '这是一个错误'
    dialog.showErrorBox('错误', '这是一个错误');
}

使用 HTML 5 API 创建子窗口

在 Electron 中还存在一种创建窗口的方式,就是使用 HTML 5 的 API 创建窗口。在 HTML 5 中提供了 window.open 方法用于打开一个子窗口,该方法返回一个 BrowserWindowProxy 对象,并且打开了一个功能受限的窗口。

window.open 方法的原型如下:

window.open(url[, title] [,attributes])
  • url:要打开页面的链接(包括本地页面路径和 Web 链接)。
  • title:设置要打开页面的标题,如果在要打开页面中已经设置了标题,那么这个参数将被忽略。
  • attributes:可以设置与窗口相关的一些属性,如窗口的宽度和高度,其中第 1 个参数是必选的,第 2 个和第 3 个参数是可选的。
function onClick_OpenWindow1() {
    // 通过 open 方法指定窗口的标题时,子窗口不能设置 <title> 标签
    win = window.open('./child.html','新的窗口','width=300,height=200')
}

控制子窗口的焦点及关闭子窗口

//获得焦点
function onClick_Focus() {
    if(win != undefined) {
       win.focus();
    }
}
//失去焦点
function onClick_Blur() {
    if(win != undefined) {
        win.blur();
    }
}

//关闭子窗口
function onClick_Close() {
    if (win != undefined) {
        //  closed 属性用于判断窗口是否已关闭
        if(win.closed)
        {
            alert('子窗口已经关闭,不需要再关闭');
            return;
        }
        win.close();
    }
}

//  调用子窗口中的打印对话框
function onClick_PrintDialog() {
    if (win != undefined) {
        win.print();
    }
}

子窗口交互

  • child.html
<!DOCTYPE html>
<html>
<head>
  <!--  指定页面编码格式  -->
  <meta charset="UTF-8">
  <!--  指定页头信息 -->
  <title>BrowserWindowProxy与open方法</title>
  <script src="event.js"></script>
</head>
<body onload="onLoad()">
    <h1>子窗口</h1>
    <button onclick="onClick_Close()">关闭</button>
    <label id="label"></label>
</body>
</html>
  • index.html
<!DOCTYPE html>
<html>
<head>
    <!--指定页面编码格式 -->
    <meta charset="UTF-8">
    <!--指定页头信息-->
    <title>BrowserWindowProxy与open方法</title>
    <script src="event.js"></script>
</head>
<body>
    <button onclick="onClick_OpenWindow()">打开子窗口</button>
    <br>
    <br>
    <button onclick="onClick_Message()">向子窗口发送消息</button>
    <br>
    <br>
    <button onclick="onClick_Eval()">向子eval方法窗口发送消息</button>
    <br>
    <br>
    <label id="label" style="font-size: large"></label>
</body>
</html>
  • event.js
const remote = require('electron').remote;
const dialog = remote.dialog;
const ipcMain = remote.ipcMain;
const {ipcRenderer} = require('electron')
ipcMain.on('close', (event, str) => {
    alert(str);
});
var win;
//创建并显示一个主窗口
function onClick_OpenWindow() {
    win = window.open('./child.html','接收消息','width=300,height=200')
}

function onClick_Message() {
    // postMessage 方法的第 1 个参数用于指定要传递的数据,第 2 个参数是来源,一个字符串类型的值,如果不知道来源,可以使用 '*'
    win.postMessage('abcd', '*');

}

var label
function onLoad() {
    label = document.getElementById('label');
    // 当使用 postMessage 方法传递数据时,接收数据的页面就会触发 message 事件
    // 并通过事件回调函数参数的 data 属性得到传过来的数据。
    window.addEventListener('message', function (e) {
        alert(e.origin);
        label.innerText = e.data
    });
}

function onClick_Close() {
    const win  =  remote.getCurrentWindow();
    //  返回数据,其中 close 是事件名
    ipcRenderer.send('close','窗口已经关闭');
    win.close();
}
//  以下代码在主窗口中:在主线程中接收 close 事件
ipcMain.on('close', (event, str) => {
    //  str参数就是字窗口返回的数据 
    alert(str);
});

function onClick_Eval() {
    //通过 eval 方法设置 child 窗口中的 label 标签
    // eval 方法用于执行子窗口中的代码,也就是说,使用 eval 方法执行的 JavaScript 代码的上下文是子窗口的
    win.eval('label.innerText="hello world"')
}

暂时先学到在这里。

英雄车云台PID调试总结

今天已经18号了,距离出车的deadline已经不远了,留给电控的时间不多了。

昨天和前天已经有好几波人轮流调了英雄车的云台,但都无功而返,眼看死期将至,决定自己还是亲自拼一把。

我想起了上一年比赛第一次接触PID的时候,连PID是啥都不知道,结果上手就是一个串级PID,六个参数直接玄学调参,调了一个月左右,最后发现反馈数据给错了!修正之后立马就调好了。

今天的情况其实也很类似,也算是反馈数据不准确吧。

今天我才知道串级PID的调试方法,之前我一直是六个参数同时变,然后波形也不看,就各种自闭,虽然我熬了很多次夜来调试,逃了好几节来调试,都无济于事,说白了就是没掌握方案,没有完全理解PID的含义。

串级PID先调内环的参数。我这里的串级PID分别是外环位置环和内环速度环,使用的是带输出上限和积分饱和上限的普通位置式PID函数。

首先只调速度环,我使用的是陀螺仪反馈的角速度信息,单位是°/s,输出的是电机的电流大小,也就是扭矩的大小。

但是自闭了一个早上之后一点进度都没有,调出来的yaw轴云台要么没有力,要么就是疯狂振荡,后来我使用J-Scope在线查看PID设定值和反馈值,以及电机的角度值的曲线才发现,陀螺仪的角速度反馈值存在两个问题。

  1. 角速度数据反馈频率低,在J-Scope中显示的曲线呈很明显的阶梯状
  2. 陀螺仪的角速度是根据加速度积分得到的,计算需要时间,所以反馈的数据在图像上显示存在相位差

上面两个特点就决定了陀螺仪的反馈数据不可用,因为角速度无法得到快速的反馈。速度环是属于内环的,内环是整个串级PID的基础,内环调不好,那整个系统就崩溃了。

在自闭了一会儿之后,我决定使用电机编码器的反馈的机械角度位置进行微分计算电机的转速。

在这里介绍一下电机的参数:

机械角度反馈范围为0~8191
反馈频率为1000Hz

从上面的信息可以算出,如果只采集相邻两次的编码器数据,那么对应的角速度大约为

这显然是不行的,所以我连续采集了24次编码器数据计算平均速度,这样子精度可以在上面的精度基础上再二四分之。

实现的代码如下:

/**
* @brief CAN通信电机的反馈数据具体解析函数
* @param 电机数据结构体
* @retval None
*/
void CanDataEncoderProcess(struct CAN_Motor *motor)
{
  int temp_sum = 0;
  motor->last_fdbPosition = motor->fdbPosition;
	motor->fdbPosition = CanReceiveData[0]<<8|CanReceiveData[1];
	motor->fdbSpeed = CanReceiveData[2]<<8|CanReceiveData[3];
  motor->last_real_position = motor->real_position;
  
  /* 电机位置数据过零处理,避免出现位置突变的情况 */
  if(motor->fdbPosition - motor->last_fdbPosition > 4096)
  {
    motor->round --;
  }else if(motor -> fdbPosition - motor->last_fdbPosition < -4096)
  {
    motor->round ++;
  }
  
	motor->real_position = motor->fdbPosition + motor->round * 8192;
  
  motor->diff = motor->real_position - motor->last_real_position;
  motor->position_buf[motor->index] = motor->diff;
  motor->index ++;
  if(motor->index == 24)
  {
    motor->index = 0;
  }
  
  for(int i=0; i<24;i++)
  {
    temp_sum += motor->position_buf[i];
  }
  motor->velocity = temp_sum*1.83;        //默认单位是8192,转换为360,毫秒转化为秒,加上这里累加了最近的24个数据,所以算式为temp_sum*360*1000/8192/24 = 1.83
}

经过上述处理之后,获取到的角速度就与角度的变化曲线对应起来了,但是曲线上看还是有很多细微的锯齿,这就导致PID中微分基本无用,因为数据在不断地上升下降,微分的作用几乎为零。

使用上述的角速度反馈之后,我直接在速度环PID中只加入了一个P,整个系统就稳定了,再经过细微的调整,整个云台的yaw轴的阻尼就特别大,用手掰会感受到很多的阻力,甚至炮管在经受大力冲击后能立马减速停止,居然达到了类似位置环的效果,非常让人满意,整个yaw轴给人的感觉就像非牛顿液体一样。

然后位置环就更简单了,加一个P就行了。最后这个云台的yaw轴的串级PID只使用了两个P就稳定了,经过测试角度精度能达到0.1度左右,完全达到视觉识别瞄准所需要的精度。

经过这次PID的调试,我对PID的理解更加深刻了。

每一层PID的计算就相当于求导,位置环PID输出的是速度,于是输出值作为内环速度环的输入值,速度环的输出是加速度,也就是力矩,所以速度环的输出直接输给电调。

这下子整个串级PID的逻辑就基本捋清楚了,特此记录一下。

一款超简洁的记事工具

网上冲浪时发现一款将极简主义发挥到极致的记事工具。在这里Mark一下。

地址

工具的介绍点这里:Mak - Inns,使用点这里:Mak

没错,打开就是个网站,这个工具只需要一个浏览器就能工作,支持markdown语法,所以还可以用来写博客。支持GitHub账号登录,简直不要太好。

实现原理

网站的工作原理很简单,就是使用localStorage,可以手动打开DevTool查看localStorage。也正因为这个,所以哪怕网页关闭,电脑关机,只要不卸载浏览器,下次打开这个网页的时候,数据就会在那乖乖等着你。这样做安全性也得到了保障。

从工具的介绍页面来看,该工具还可以很方便地嵌入页面中,非常实用。

源代码

目前作者还在整理代码,根据他说的,整理好之后会开源出来。GitHub地址见这里

GitHub access_token无法获取

最近这几天一直在尝试使用GitHub的issue和comments来重构我的博客,这样可以给博客加上评论功能,而且还能自带提醒功能。

目前其实已经有gitment这个成熟的模块,但就在前不久作者的网站安全证书过期了,导致gitment服务出了问题。

于是我手动下载源代码,用node.js搭建了一个服务器,但是不知道为什么,在自己的电脑上没有任务问题,搬到服务器后,程序明明正在监听端口,我就是访问不了。

没办法,只剩下自己写了。

我参考了GitHub的API,申请了一个GitHub的OAuth应用,拿到了client_idclient_secret,然后参照教程一路走下来,似乎没啥问题,但是在尝试使用API添加comments的时候总是提示没有访问仓库的权限。

后来才发现原来申请code的时候要指定scope为public_repo,否则默认的就没有修改权限。

然后就剩下最后一个问题,就是获取access_token的时候的跨域访问的问题。我使用了jsonp实现跨域访问,response的数据也能在开发者工具中看到,但是一直有以下报错提示:
github_mime_problem_1

自闭了一下午也没有解决,后来我发现:
github_mime_problem_2

对于script脚本内容,MIME的范围是

  • "application/ecmascript"
  • "application/javascript"
  • "application/x-javascript"
  • "text/ecmascript"
  • "text/javascript"
  • "text/jscript"
  • "text/x-javascript"
  • "text/vbs"
  • "text/vbscript"

但它自己又指定了返回的内容是application/x-www-form-urlencoded

暂时没想到怎么办。

JGA25-371电机驱动

这段时间我在帮学校Robocon战队做辅导,遇到了JGA25-371电机。

以前我还没遇到过这种电机,一直用的是大疆的三相无刷电机,而这个是带编码器和减速箱的直流电机,一共6根线,其中四根线是编码器的,两根线是电机的电源线。

电机的驱动很简单,这里使用了L298N驱动器,使用三根线控制,两根线控制电机的转向,一根线输出PWM波控制输出的电压,进而控制电机的力矩。

这个电机关键的地方在于获取它的转速和转向,也就是获取编码器的值。我之前使用的大疆电机采用的霍尔传感器,而这个电机采用的是光电编码器,它的具体介绍可以看这个网站
编码器速度和方向检测,371电机方向与速度检测,stm32编码器接口模式

最关键的就是下面这张图,只要理解了这张图就能读取转向和转速了。

我采用了两种方法获取电机的转速。第一种是使用定时器的输入捕获功能,设置为上升沿触发,通过记录A相或B相两次上升沿的间隔来计算电机的转速,我测量的是A相,然后在中断中判断B相的电平高低来区分电机的转向。

另一种方法是使用GPIO的外部中断,配置为上升沿中断,给每个电机设置一个计数,在中断中对计数进行++操作,通过计数的大小来计算转速。

获取到转速就可以进行PID控制了,上面的原理很简单。

这里可以提一下,在使用定时器输入捕获的时候,如果在中断中判断是又哪个通道产生的:

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
  if((htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1))
  {
    //...
  }
  else if((htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1))
  {
    //...
  }
  else if((htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1))
  {
    //...
  }
  //...
}

这样就可以啦!

RoboMaster 2018裁判系统学生接口通信协议

通信协议格式
FrameHeader(5-Byte) CmdID(2-Byte) Data(n-Byte) FrameTail(2-Byte,CRC16,整包校验)

FrameHeader格式

偏移位置 大小(字节) 详细描述
SOF 0 1 帧起始字节,固定值为0xA5
DataLength 1 2 数据段Data长度
Seq 3 1 包序号
CRC8 4 1 帧头CRC8校验

CmdID

命令码 数据段长度(Byte) 功能说明
0x0001 8 比赛机器人状态,10Hz频率周期发送
0x0002 1 伤害数据,受到伤害时发送
0x0003 6 实时射击数据,发射弹丸时发送
0x0004 20 实时功率和热量数据,50Hz频率周期发送
0x0005 2 实时场地交互数据,检测到RFID卡时10Hz周期发送
0x0006 1 比赛结果数据,比赛结束时发送一次
0x0007 2 Buff状态,任意Buff状态改变时发送一次
0x0008 16 机器人位置和枪口朝向信息,50Hz频率周期发送
0x0010 13 参赛队自定义数据,用于显示在操作界面,限频10Hz

机器人比赛状态(0x0001)

字节偏移 大小 说明
0 2 当前阶段剩余时间,单位s
2 1

当前比赛阶段

0:未开始比赛

1:准备阶段

2:自检阶段

3:5s倒计时

4:对战中

5:比赛结算中

3 1 机器人当前等级
4 2 机器人当前血量
6 2 机器人满血量

结构体定义:

typedef __packed struct
{
	uint16_t stageRemainTime;
	uint8_t gameProgress;
	uint8_t robotLevel;
	uint16_t remainHP;
	uint16_t maxHP;
}extGameRobotState_t;

伤害数据(0x0002)

字节偏移 大小 说明
0 1

0-3bits:若变化类型为装甲伤害时,标识装甲ID

0x0: 0号装甲(前)

0x1: 1号装甲(左)

0x2: 2号装甲(后)

0x3: 3号装甲(右)

0x4: 4号装甲(上1)

0x5: 5号装甲(上2)

其他保留

4-7bits: 血量变化类型

0x0: 装甲伤害(受到攻击)

0x1: 模块掉线

结构体定义:

typedef __packed struct
{
	uint8_t armorType : 4;
	uint8_t hurtType : 4;
}extRobotHurt_t;

实时射击信息(0x0003)

字节偏移 大小 说明
0 1

弹丸类型

1: 17mm弹丸

2: 42mm弹丸

1 1 弹丸射频,单位:发每秒
2 4 弹丸射速,单位:米每秒

结构体定义:

typedef __packed struct
{
	uint8_t bulletType;
	uint8_t bulletFreq;
	float bulletSpeed;
}extShootData_t;

实时功率热量数据(0x0004)

字节偏移 大小 说明
0 4 底盘输出电压,单位:伏
4 4 底盘输出电流,单位:安
8 4 底盘输出功率,单位:瓦
12 4 底盘功率缓冲,单位:瓦
16 2 17mm枪口热量
18 2 42mm枪口热量

结构体定义:

typedef __packed struct
{
	float chassisVolt;
	float chassisCurrent;
	float chassisPower;
	float chassisPowerBuffer;
	uint16_t shooterHeat0;
	uint16_t shooterHeat1;
}extPowerHeatData_t;

场地交互数据(0x0005)

字节偏移 大小 说明
0 1

卡类型

0: 攻击加成卡

1: 防御加成卡

2: 红方加血卡

3: 蓝方加血卡

4: 红方治疗卡

5: 蓝方治疗卡

6: 红方冷却卡

7: 蓝方冷却卡

8: 碉堡卡

9: 保留

10: 资源岛卡

11: ICRA大能量机关打击点卡

1 1 卡索引号,可用于区分不同区域

结构体定义:

typedef __packed struct
{
	uint8_t cardType;
	uint8_t cardldx;
}extRfidDetect_t;

比赛胜负数据(0x0006)

字节偏移 大小 说明
0 1

比赛结果

0: 平局

1: 红方胜

2: 蓝方胜

结构体定义:

typedef __packed struct
{
	uint8_t winner;
}extGameResult_t;

Buff获取数据(0x0007)

字节偏移 大小 说明
0 2

Buff类型,1表示有效

bit0: 补血点回血

bit1: 工程机器人回血

bit2: 治疗卡回血

bit3: 资源岛防御

bit4: 己方激活大能量机关

bit5: 地方激活大能量机关

bit6: 己方激活小能量机关

bit7: 地方激活小能量机关

bit8: 加速冷却

bit9: 碉堡防御

bit10: 百分百防御

bit11: 无哨兵基地防御

bit12: 有哨兵基地防御

结构体定义:

typedef __packed struct
{
	uint16_t buffMusk;
}extBuffMusk_t;

机器人位置朝向信息(0x0008)

字节偏移 大小 说明
0 4 位置X坐标值,单位:米
4 4 位置Y坐标值,单位:米
8 4 位置Z坐标值,单位:米
12 4 枪口朝向角度值,单位:米

结构体定义:

typedef __packed struct
{
	float x;
	float y;
	float z;
	float yaw;
}extGameRobotPos_t;

参赛队自定义数据(0x0100)

字节偏移 大小 说明
0 4 自定义数据1
4 4 自定义数据2
8 4 自定义数据3
12 1 自定义数据4

结构体定义:

typedef __packed struct
{
	float data1;
	float data2;
	float data3;
	uint8_t mask;
}extShowData_t;

CRC校验代码示例

//crc8 generator polynomial:G(x)=x8+x5+x4+1
const uint8_t CRC8_INIT     = 0xff;
const uint8_t CRC8_TAB[256] = {
  0x00, 0x5e, 0xbc, 0xe2, 0x61, 0x3f, 0xdd, 0x83, 0xc2, 0x9c, 0x7e, 0x20, 0xa3, 0xfd, 0x1f, 0x41,
  0x9d, 0xc3, 0x21, 0x7f, 0xfc, 0xa2, 0x40, 0x1e, 0x5f, 0x01, 0xe3, 0xbd, 0x3e, 0x60, 0x82, 0xdc,
  0x23, 0x7d, 0x9f, 0xc1, 0x42, 0x1c, 0xfe, 0xa0, 0xe1, 0xbf, 0x5d, 0x03, 0x80, 0xde, 0x3c, 0x62,
  0xbe, 0xe0, 0x02, 0x5c, 0xdf, 0x81, 0x63, 0x3d, 0x7c, 0x22, 0xc0, 0x9e, 0x1d, 0x43, 0xa1, 0xff,
  0x46, 0x18, 0xfa, 0xa4, 0x27, 0x79, 0x9b, 0xc5, 0x84, 0xda, 0x38, 0x66, 0xe5, 0xbb, 0x59, 0x07,
  0xdb, 0x85, 0x67, 0x39, 0xba, 0xe4, 0x06, 0x58, 0x19, 0x47, 0xa5, 0xfb, 0x78, 0x26, 0xc4, 0x9a,
  0x65, 0x3b, 0xd9, 0x87, 0x04, 0x5a, 0xb8, 0xe6, 0xa7, 0xf9, 0x1b, 0x45, 0xc6, 0x98, 0x7a, 0x24,
  0xf8, 0xa6, 0x44, 0x1a, 0x99, 0xc7, 0x25, 0x7b, 0x3a, 0x64, 0x86, 0xd8, 0x5b, 0x05, 0xe7, 0xb9,
  0x8c, 0xd2, 0x30, 0x6e, 0xed, 0xb3, 0x51, 0x0f, 0x4e, 0x10, 0xf2, 0xac, 0x2f, 0x71, 0x93, 0xcd,
  0x11, 0x4f, 0xad, 0xf3, 0x70, 0x2e, 0xcc, 0x92, 0xd3, 0x8d, 0x6f, 0x31, 0xb2, 0xec, 0x0e, 0x50,
  0xaf, 0xf1, 0x13, 0x4d, 0xce, 0x90, 0x72, 0x2c, 0x6d, 0x33, 0xd1, 0x8f, 0x0c, 0x52, 0xb0, 0xee,
  0x32, 0x6c, 0x8e, 0xd0, 0x53, 0x0d, 0xef, 0xb1, 0xf0, 0xae, 0x4c, 0x12, 0x91, 0xcf, 0x2d, 0x73,
  0xca, 0x94, 0x76, 0x28, 0xab, 0xf5, 0x17, 0x49, 0x08, 0x56, 0xb4, 0xea, 0x69, 0x37, 0xd5, 0x8b,
  0x57, 0x09, 0xeb, 0xb5, 0x36, 0x68, 0x8a, 0xd4, 0x95, 0xcb, 0x29, 0x77, 0xf4, 0xaa, 0x48, 0x16,
  0xe9, 0xb7, 0x55, 0x0b, 0x88, 0xd6, 0x34, 0x6a, 0x2b, 0x75, 0x97, 0xc9, 0x4a, 0x14, 0xf6, 0xa8,
  0x74, 0x2a, 0xc8, 0x96, 0x15, 0x4b, 0xa9, 0xf7, 0xb6, 0xe8, 0x0a, 0x54, 0xd7, 0x89, 0x6b, 0x35,
};

uint16_t       CRC_INIT        = 0xffff;
const uint16_t wCRC_Table[256] = {
  0x0000, 0x1189, 0x2312, 0x329b, 0x4624, 0x57ad, 0x6536, 0x74bf,
  0x8c48, 0x9dc1, 0xaf5a, 0xbed3, 0xca6c, 0xdbe5, 0xe97e, 0xf8f7,
  0x1081, 0x0108, 0x3393, 0x221a, 0x56a5, 0x472c, 0x75b7, 0x643e,
  0x9cc9, 0x8d40, 0xbfdb, 0xae52, 0xdaed, 0xcb64, 0xf9ff, 0xe876,
  0x2102, 0x308b, 0x0210, 0x1399, 0x6726, 0x76af, 0x4434, 0x55bd,
  0xad4a, 0xbcc3, 0x8e58, 0x9fd1, 0xeb6e, 0xfae7, 0xc87c, 0xd9f5,
  0x3183, 0x200a, 0x1291, 0x0318, 0x77a7, 0x662e, 0x54b5, 0x453c,
  0xbdcb, 0xac42, 0x9ed9, 0x8f50, 0xfbef, 0xea66, 0xd8fd, 0xc974,
  0x4204, 0x538d, 0x6116, 0x709f, 0x0420, 0x15a9, 0x2732, 0x36bb,
  0xce4c, 0xdfc5, 0xed5e, 0xfcd7, 0x8868, 0x99e1, 0xab7a, 0xbaf3,
  0x5285, 0x430c, 0x7197, 0x601e, 0x14a1, 0x0528, 0x37b3, 0x263a,
  0xdecd, 0xcf44, 0xfddf, 0xec56, 0x98e9, 0x8960, 0xbbfb, 0xaa72,
  0x6306, 0x728f, 0x4014, 0x519d, 0x2522, 0x34ab, 0x0630, 0x17b9,
  0xef4e, 0xfec7, 0xcc5c, 0xddd5, 0xa96a, 0xb8e3, 0x8a78, 0x9bf1,
  0x7387, 0x620e, 0x5095, 0x411c, 0x35a3, 0x242a, 0x16b1, 0x0738,
  0xffcf, 0xee46, 0xdcdd, 0xcd54, 0xb9eb, 0xa862, 0x9af9, 0x8b70,
  0x8408, 0x9581, 0xa71a, 0xb693, 0xc22c, 0xd3a5, 0xe13e, 0xf0b7,
  0x0840, 0x19c9, 0x2b52, 0x3adb, 0x4e64, 0x5fed, 0x6d76, 0x7cff,
  0x9489, 0x8500, 0xb79b, 0xa612, 0xd2ad, 0xc324, 0xf1bf, 0xe036,
  0x18c1, 0x0948, 0x3bd3, 0x2a5a, 0x5ee5, 0x4f6c, 0x7df7, 0x6c7e,
  0xa50a, 0xb483, 0x8618, 0x9791, 0xe32e, 0xf2a7, 0xc03c, 0xd1b5,
  0x2942, 0x38cb, 0x0a50, 0x1bd9, 0x6f66, 0x7eef, 0x4c74, 0x5dfd,
  0xb58b, 0xa402, 0x9699, 0x8710, 0xf3af, 0xe226, 0xd0bd, 0xc134,
  0x39c3, 0x284a, 0x1ad1, 0x0b58, 0x7fe7, 0x6e6e, 0x5cf5, 0x4d7c,
  0xc60c, 0xd785, 0xe51e, 0xf497, 0x8028, 0x91a1, 0xa33a, 0xb2b3,
  0x4a44, 0x5bcd, 0x6956, 0x78df, 0x0c60, 0x1de9, 0x2f72, 0x3efb,
  0xd68d, 0xc704, 0xf59f, 0xe416, 0x90a9, 0x8120, 0xb3bb, 0xa232,
  0x5ac5, 0x4b4c, 0x79d7, 0x685e, 0x1ce1, 0x0d68, 0x3ff3, 0x2e7a,
  0xe70e, 0xf687, 0xc41c, 0xd595, 0xa12a, 0xb0a3, 0x8238, 0x93b1,
  0x6b46, 0x7acf, 0x4854, 0x59dd, 0x2d62, 0x3ceb, 0x0e70, 0x1ff9,
  0xf78f, 0xe606, 0xd49d, 0xc514, 0xb1ab, 0xa022, 0x92b9, 0x8330,
  0x7bc7, 0x6a4e, 0x58d5, 0x495c, 0x3de3, 0x2c6a, 0x1ef1, 0x0f78
};

/*
** Descriptions: CRC8 checksum function
** Input: Data to check,Stream length, initialized checksum
** Output: CRC checksum
*/
uint8_t get_crc8_check_sum(uint8_t* pchMessage, uint16_t dwLength, uint8_t ucCRC8)
{
  uint8_t ucIndex;
  while (dwLength--)
  {
    ucIndex = ucCRC8 ^ (*pchMessage++);
    ucCRC8  = CRC8_TAB[ucIndex];
  }
  return (ucCRC8);
}

/*
** Descriptions: CRC8 Verify function
** Input: Data to Verify,Stream length = Data + checksum
** Output: True or False (CRC Verify Result)
*/
uint8_t verify_crc8_check_sum(uint8_t* pchMessage, uint16_t dwLength)
{
  uint8_t ucExpected = 0;
  if ((pchMessage == 0) || (dwLength <= 2))
    return 0;
  ucExpected = get_crc8_check_sum(pchMessage, dwLength - 1, CRC8_INIT);
    return (ucExpected == pchMessage[dwLength - 1]);
}

/*
** Descriptions: append CRC8 to the end of data
** Input: Data to CRC and append,Stream length = Data + checksum
** Output: True or False (CRC Verify Result)
*/
void append_crc8_check_sum(uint8_t* pchMessage, uint16_t dwLength)
{
  uint8_t ucCRC = 0;
  if ((pchMessage == 0) || (dwLength <= 2))
    return;
  ucCRC                    = get_crc8_check_sum((uint8_t*)pchMessage, dwLength - 1, CRC8_INIT);
  pchMessage[dwLength - 1] = ucCRC;
}

/*
** Descriptions: CRC16 checksum function
** Input: Data to check,Stream length, initialized checksum
** Output: CRC checksum
*/
uint16_t get_crc16_check_sum(uint8_t* pchMessage, uint32_t dwLength, uint16_t wCRC)
{
  uint8_t chData;
  if (pchMessage == NULL)
  {
    return 0xFFFF;
  }
  while (dwLength--)
  {
    chData = *pchMessage++;
    (wCRC) = ((uint16_t)(wCRC) >> 8) ^ wCRC_Table[((uint16_t)(wCRC) ^ (uint16_t)(chData)) & 0x00ff];
  }
  return wCRC;
}

/*
** Descriptions: CRC16 Verify function
** Input: Data to Verify,Stream length = Data + checksum
** Output: True or False (CRC Verify Result)
*/
uint8_t verify_crc16_check_sum(uint8_t* pchMessage, uint32_t dwLength)
{
  uint16_t wExpected = 0;
  if ((pchMessage == NULL) || (dwLength <= 2))
  {
    return 0;
  }
  wExpected = get_crc16_check_sum(pchMessage, dwLength - 2, CRC_INIT);
    return ((wExpected & 0xff) == pchMessage[dwLength - 2] && ((wExpected >> 8) & 0xff) == pchMessage[dwLength - 1]);
}

/*
** Descriptions: append CRC16 to the end of data
** Input: Data to CRC and append,Stream length = Data + checksum
** Output: True or False (CRC Verify Result)
*/
void append_crc16_check_sum(uint8_t* pchMessage, uint32_t dwLength)
{
  uint16_t wCRC = 0;
  if ((pchMessage == NULL) || (dwLength <= 2))
  {
    return;
  }
  wCRC                     = get_crc16_check_sum((uint8_t*)pchMessage, dwLength - 2, CRC_INIT);
  pchMessage[dwLength - 2] = (uint8_t)(wCRC & 0x00ff);
  pchMessage[dwLength - 1] = (uint8_t)((wCRC >> 8) & 0x00ff);
}

UART通信配置,波特率115200,数据位8,停止位1,校验位无,流控制无。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.