详细说明Klipper固件对机器的运动控制逻辑,一条运动指令是如何实现运动的。

目录

1. G1指令到预移动队列LookAhead.queue

G1

cmd_G1

ToolHead.move

2.Move对象

3.从预移动队列到移动链表

ToolHead._check_pause()

梯形加减速Move.set_junction

预计算平滑处理Move.calc_junction

ToolHead.trapq

 4. 从移动链表到步进脉冲发送下位机

运动学配置

运动学初始化

note_mcu_movequeue_activity

_advance_flush_time

5.脉冲生成

itersolve_generate_steps

itersolve_gen_steps_range

stepcompress_append

6.指令发送

参数关联

steppersync_flush

serialqueue_send_batch

7.MCU指令接收与脉冲发送

MCU对于上位机的变量挂点

command_config_stepper

MCU指令接收command_queue_step

脉冲发送


        在上一篇《2.Klipper开发篇:Klipper上位机源码分析》中提到G1指令是整个打印指令最普遍,用得最多,也是最基本的移动指令,很多指令(如G2,G3等)其实最后都转换成G1指令来执行。因此,这里以G1指令来说明运动是如何控制的。

1. G1指令到预移动队列LookAhead.queue

共经历如下过程

G1 -> cmd_G1 -> ToolHead.move(pos,speed) ->LookAheadQueue.add_move(move)

        具体每一步说明:

  • G1

        从指令中解析出G1指令,并调用cmd_G1函数

  • cmd_G1

        进一步解析出要移动的坐标,有相对坐标系和绝对坐标系之分,GCodeMove.last_position记录终点坐标:

        相对坐标系:last_position=last_position+坐标值

        绝对坐标系:last_position=base_position+坐标值

        last_position以及speed,传给ToolHead.move

  • ToolHead.move

        klipper运动控制核心由ToolHead类来实现和管理,ToolHead起始坐标commanded_pos,与move函数传入的终点坐标,移动速度等共同计算,创建一个Move对象

        对Move对象进行运动学(kinematics)检查kin.check_move(move)以及E轴检查extruder.check_move(move),检查该段移动是否适合本运动学结构(例如直角坐标系机器cartesian检查是否已经复位等),或者是否适合E轴移动(当前温度高于挤出阀值等),如果检查通过,则表示该段移动完全可以在机器上实现。

       然后将move对象放进预移动队列LookAheadQueue.queue中,稍后将这些Move对象一个一个的取出,实现真正移动。

commanded_pos[:] = move.end_pos #记录结束位置作为下一段移动的开始位置
lookahead.add_move(move) #将move追加到预移动队列中

2.Move对象

        保存有一段移动所需要的若干要素,单位是毫米和秒。klipper移动由XYZ笛卡尔坐标空间的运动学移动+E轴移动组成,因此,Move包括XYZ+E的要素:

start_pos:起始位置,即(x_{0},y_{0},z_{0})

end_pos:结束位置,即(x_{1},y_{1},z_{1})

accel:加速度a

axes_d:各轴位移,即XYZE四轴自己所进行的位移,结束坐标-起始坐标所得,即dx=x_{1}-x_{0}dy=y_{1}-y_{0}dz=z_{1}-z_{0}de=e_{1}-e_{0}

move_d:空间位移,即XYZ三轴联动下,打印头实际位移,即d=\sqrt{dx^{2}+dy^{2}+dz^{2}}

axes_r:各轴斜率,例如X轴:x_r =\frac{dx}{d}

max_start_v2:起始速度,默认是0,为了简化两段移动的连接处起始速度,这里实际是速度的平方形式,下同

max_cruise_v2:指令指定该段移动的巡航速度

delta_v2:本段移动最高的速度差,即\Delta v^{2}=2as

3.从预移动队列到移动链表

当只有一个移动段时:LookAhead.add_move() -> _check_pause() -> _priming_handler() -> _flush_lookahead() -> LookAhead.flush() -> _process_move() -> trapq_append()

多个移动段时:LookAhead.add_move() -> Move.calc_junction() -> LookAhead.flush() -> _process_move() -> trapq_append()

        当LookAheadQueue只有一个移动对象时,不需要计算两个移动段之间的junction连接处的速度,直接采用Move默认要素进行移动,一般这种情况是在空闲状态下出现。

ToolHead._check_pause()

        need_check_pause与special_queuing_state 在初始化后或者全部移动结束(ToolHead._flush_lookahead())后分别被标为-1和"NeedPrime",而print_time为大于或等于0(追踪发现其它模块可能会调用ToolHead.register_lookahead_callback(),这里空闲时会调用get_last_move_time(),进一步调用_calc_print_time()更新print_time)的值,因此ToolHead.move函数中_check_pause()一定会被调用。

        _check_pause()中会注册_priming_handler函数,在稍后被执行:

if self.special_queuing_state:
    ...
    if self.priming_timer is None:
        self.priming_timer = self.reactor.register_timer(self._priming_handler)
    ...
    wtime = eventtime + max(0.100, buffer_time - BUFFER_TIME_LOW)
    self.reactor.update_timer(self.priming_timer, wtime)

        从代码可以看出,在0.1秒以后如果执行了reactor.pause(),则会执行_priming_handler函数,该函数会执行_flush_lookahead() -> LookAheadQueue.flush() -> ToolHead._process_moves等后续步骤实现步进脉冲生成。但是,本次_check_pause()并不会执行reactor.pause(),原因是print_time是小于est_print_time的,因此buffer_time是负值,进而pause_time也是一个负值,它直接到致退出循环,结束_check_pause()函数:


while 1:
    pause_time = buffer_time - BUFFER_TIME_HIGH
    if pause_time <= 0.:
        break
    ...
    eventtime = self.reactor.pause(eventtime + min(1., pause_time))

        如果只有一段移动,后面不再有ToolHead.move函数被调用,则在其它地方遇到reactor.pause()时,会调用_priming_handler函数,这样就可以让这一段移动得以执行了。

        如果有多段移动,后面继续调用ToolHead.move函数,此时LookAheadQueue.add_move函数会调用LookAheadQueue.flush()->ToolHead._process_moves(),这样就会直接从队列中取出移动对象,生成步进脉冲,实现移动。

        在ToolHead._process_moves函数中,ToolHead.special_queuing_state会被置为"",这样就算后续在_check_pause()函数中被注册的_priming_handler被调用,移动也不会被执行,因为它要求ToolHead.special_queuing_state为"Priming":

def _priming_handler(self, eventtime):
    ...
    try:
        if self.special_queuing_state == "Priming":
            self._flush_lookahead()
    ...

        _process_moves函数最后会调用_advance_move_time函数,该函数会更新print_time至这一段移动后的时间,因此,它会大于当前时间,当_check_pause再次被执行时,等待循环会一直执行,直到当前时间与打印时间的差小于BUFFER_TIME_HIGH为止(即2秒),所以,预移动队列中的移动段数不会无限制的加入,脉冲缓存器也不会无限制的加入脉冲,它的缓存的移动时间不得超过BUFFER_TIME_HIGH。

梯形加减速Move.set_junction

        每段移动会变成一个叫梯形加减速(trapezoid generator)的过程,即加速、匀速巡航、减速三个段,巡航速度总是大于或等于加速段的起始速度和减速段的终点速度,三个段持续的时间都有可能为0,例如巡航速度与加速段的起始速度相同时,则加速段时间为0,梯形加减速时间由函数Move.set_junction实现。

预计算平滑处理Move.calc_junction

        两段以上移动时,例如下图:

        如果move1从0加速到巡航速度,再减速至0,然后转到move2从0加速到巡航速度,这样会造成抖动,需要计算一个合适的junction速度,使得move1并不需要减速到0,而是减速到连接速度,再切到move2从这个速度加速到巡航速度,这样使运动更加平滑。

        平滑处理预计算由函数Move.calc_junction()实现,将由另外一篇文章介绍。

ToolHead.trapq

        经过计算后,若干段移动将会产生移动所需的一切要素,包括:

print_time:这段移动的开始时间

move.accel_t, move.cruise_t, move.decel_t:加速时间,巡航时间,减速时间

move.start_pos[0], move.start_pos[1], move.start_pos[2]:起点位置XYZ

move.axes_r[0], move.axes_r[1], move.axes_r[2]:斜率

move.start_v, move.cruise_v, move.accel:起始速度,巡航速度,加速度

        在函数ToolHead._process_moves中,每个move对象的上面这些参数,通过函数trapq_append(在chelper/trapq.c中定义)传入,根据(梯形加减速)加速时间,巡航时间和减速时间,申请三段移动结构指针struct move*,将每段移动参数赋值后,挂到ToolHead.trapq上面(挤出机的是挂到PrinterExtruder.trapq)上面,它实际是一个struct trapq*指针,在ToolHead.__init__(挤出机在PrinterExtruder.__init__)由trapq_alloc申请得来。

void __visible
trapq_append(struct trapq *tq, double print_time,...) {
    ...
    if (accel_t) {
        struct move *m = move_alloc();
        ...
        trapq_add_move(tq, m);
        ...
    }
    if (cruise_t) {
        struct move *m = move_alloc();
        ...
        trapq_add_move(tq, m);
        ...
    }
    if (decel_t) {
        struct move *m = move_alloc();
        ...
        trapq_add_move(tq, m);
    }
}

移动链表trapq每个节包含一段移动所需的所有信息:

print_time:起始时间

move_t:移动需要的时间

start_v:起始速度

half_accel:半加速度(加速度/2,为了方便后续计算,先除以2作准备)

start_pos:起始位置

axes_r:斜率

 4. 从移动链表到步进脉冲发送下位机

运动学配置

        在ToolHead.__init__中进行运动学对象指定,因为3D打印机有不同的运动学结构,算法是不一样的,所以,需要指定符合打印机的运动学对象。在ToolHead类中,是通过读取配置文件中kinematics的配置来获取当前打印机的运动学结构的,相关代码如下:

kin_name = config.get('kinematics')
try:
    mod = importlib.import_module('kinematics.' + kin_name)
    self.kin = mod.load_kinematics(self, config)

        例如,当printer.cfg中kinematics配置是这样的时候:

        它是一般的直角坐标系打印机cartesian类型,那么上面实际就是调用klippy/kinematics/cartesian.py中的load_kinematics函数,它实际是构造一个CartKinematics对象。

运动学初始化

        在相关运动学(如CartKinematics,CoreXYKinematics等)__init__函数中,调用LookupMultiRail(或PrinterRail)创建各电机对象,这些对象调用setup_itersolve()函数设置运动学迭代器。


class CartKinematics:
    def __init__(self, toolhead, config):
        ...
        self.rails = [stepper.LookupMultiRail(config.getsection('stepper_' + n))
            for n in 'xyz']
        for rail, axis in zip(self.rails, 'xyz'):
            rail.setup_itersolve('cartesian_stepper_alloc', axis.encode())
        ...

        Stepper.setup_itersolve函数中,调用cartesian_stepper_alloc(各运动学名称不一样)来创建(函数定义在klippy/chelper/kin_cartesian.c中)创建sk,进一步调用Stepper.set_stepper_kinematics(sk)完成运动学指定(实际将sk赋值给变量Stepper._stepper_kinematics)。

        调用itersolve_set_stepcompress挂一个步进序列_stepqueue到sk上(赋值给sk->sc),Stepper._stepqueue由函数stepcompress_alloc(在chelper/stepcompress.c中定义)申请并初始化,它同时被挂到MCU._stepqueues上:

class MCU_stepper:
    def __init__():
        ...
        self._mcu.register_stepqueue(self._stepqueue)
    def setup_itersolve(self, alloc_func, *params):
        ...
        sk = ffi_main.gc(getattr(ffi_lib, alloc_func)(*params), ffi_lib.free)
        self.set_stepper_kinematics(sk)
    def set_stepper_kinematics(self, sk):
        ...
        self._stepper_kinematics = sk
        ...
        ffi_lib.itersolve_set_stepcompress(sk, self._stepqueue, self._step_dist)
        ...

关于sk说明:

  • 有几个电机,就创建几个Stepper对象,一个Stepper对象对应一个sk。
  • sk是一个结构体,定义在klippy/chelper/itersolve.h中,它包含了该电机的每步移动距离step_dist(单位mm/step),电机位置commanded_pos,步进序列sc,移动链表tq,刷新时间last_flush_time等等。
    • 移动链表共用,所有电机的sk->tq = ToolHead.trapq,挤出机单独有自己的trapq
    • 每个电机的sk->sc独立,都会被加到MCU._stepqueues上
  • 电机位置commanded_pos,不一定与空间的xyze对应,如corexy的x或y位置实际是由两电机决定,因此单一电机的位置并不能直接对应实际空间位置,在每一个sk变量申请函数(如cartesian_stepper_alloc)中都会指定calc_position_cb函数,该函数将空间xyz坐标转换成电机位置。
  • 刷新时间last_flush_time,用以记录上一次产生脉冲的时间,在函数itersolve_generate_steps中被更新,以便找到本次应该从tq中哪个move开始产生脉冲。

        itersolve_set_stepcompress不旦指定了步进序列,还传入了该电机的step_dist,实际就是赋值给sk的sc和step_dist。

        最后将步进产生函数Stepper.generate_steps追加到ToolHead.step_generators,以便后面调用该函数产生步进序列。

note_mcu_movequeue_activity

        ToolHead._process_moves函数中,将各段移动挂到tq后,执行note_mcu_movequeue_activity(), 主要是启动_flush_handler()函数,它被注册在reactor里面的一个定时函数,这里要求它立即启动。

_advance_flush_time

        该函数在_process_moves和_flush_handler都被执行,主要是调用step_generators,产生步进脉冲,调用MCU.flush_moves向下位机发送脉冲命令,这里后面再详解。

        _process_moves调用的是上一级的_advance_move_time函数,它更新print_time为整个移动链表最后的时间,循环调用_advance_flush_time,直到flush_time大于等于print_time,表明已经将sk->tq上所有移动脉冲产生完并发送。

        _flush_handler也会不断的执行_advance_flush_time,直到last_flush_time大于等于need_flush_time,注意,它与_advance_move_time有相通之处,need_flush_time在_process_moves调用note_mcu_movequeue_activity时被设定,其实就是整个链表移动的最后时间,也即print_time,last_flush_time其实与flush_time一样,因此,_flush_handler实际也是一个_advance_move_time函数,这里是保证所有脉冲都得到生成和发送。

5.脉冲生成

itersolve_generate_steps

        在函数_advance_flush_time中执行的ToolHead.step_generators脉冲生成函数,实际执行的是MCU_stepper.generate_steps,该函数最终调用的函数是itersolve_generate_steps,它定义在chelper/itersolve.c中。

        itersolve_generate_steps函数进行时间定位,因为前一次脉冲生成可能只完成运动链表的一部分,所以,需要定位未完成的一部分。

while (last_flush_time >= m->print_time + m->move_t) //找到上次发送脉冲结束的节点
    m = list_next_entry(m, node);

        之后函数会进行一些补尝运算,最终会调用itersolve_gen_steps_range函数为这一段移动生成脉冲。

itersolve_gen_steps_range

        itersolve_gen_steps_range函数传入要生成脉冲的起始时间和终点时间,它们与m->print_time的差即是这一段移动相对时间。该函数会生成start到end这一时间段本电机所有的脉冲点时间,它使用割线法来迭代求解每个脉冲应该出现的时间。

for(;;) {
    ...
    double guess_dist = guess.position - target;
    double og_dist = old_guess.position - target;
    //割线法公式Xn+1 = Xn - F(Xn) * (Xn - Xn-1) / (F(Xn) - F(Xn-1))
    //变形后得Xn+1=(Xn-1 * F(Xn) - Xn * F(Xn-1)) / (F(Xn) - F(Xn-1))
    double next_time = ((old_guess.time*guess_dist - guess.time*og_dist)
        / (guess_dist - og_dist));
    ...
    guess.time = next_time;
    guess.position = calc_position_cb(sk, m, next_time);//计算next_time后的位置
    guess_dist = guess.position - target;
    //当估计值逼近target,则把guess.time作为脉冲时间保存到sc上
    if (fabs(guess_dist) > .000000001) {
        ...
        continue;
    }
    ret = stepcompress_append(sk->sc, sdir, m->print_time, guess.time);
    ...
}

        每产生一个脉冲,就会调用stepcompress_append一次。

stepcompress_append

        该函数的核心是将传进来的脉冲时间换算成MCU频率数,并调用queue_append函数将它追加到sc->queue_next上。

int stepcompress_append(sc, sdir, print_time, step_time) {
    ...
    double offset = print_time - sc->last_step_print_time;
    double rel_sc = (step_time + offset) * sc->mcu_freq; //脉冲时间换算成MCU的频率数(整数)
    uint64_t step_clock = sc->last_step_clock + (uint64_t)rel_sc;
    if(sc->next_step_clock) {
        queue_append(sc);
        ...
    }
    sc->next_step_clock = step_clock;
    ...
}
int queue_append(sc) {
    ...
    *sc->queue_next++ = sc->next_step_clock;
    sc->next_step_clock = 0;
    return 0;
}

        注意,sc->queue_next和sc->queue_pos是数组sc->queue的两个游标,sc->queue_next代表新加入的脉冲时间位置,而sc->queue_pos是传到MCU后的脉冲时间位置,从这里可以看出,脉冲时间产生后,将各脉冲时间装到一个sc->queue的数组上。

        sc->queue是在queue_append_extend函数中申请的,初始时,sc->queue_next和sc->queue_pos相同且为0,因此触发queue_append函数中第二个条件,这里直接调用queue_append_extend函数申请sc->queue空间并初始赋值sc->queue_next和sc->queue_pos。

6.指令发送

参数关联

        上面提到每个电机的sk->sc都被挂到MCU._stepqueues数组上,在steppersync_alloc函数中,被赋值到ss->sc_list上,该函数返回ss,赋值给MCU._steppersync上,因此,每个电机sk->sc实际被赋值到MCU._steppersync->sc_list中。

steppersync_flush

        在ToolHead._advance_flush_time函数最后调用MCU.flush_moves将移动信息发送到MCU,该函数实际是执行steppersync_flush,定义在chelper/stepcompress.c中,steppersync_flush进一步调用stepcompress_flush,再进一步调用queue_flush:

MCU.flush_moves(time) -> steppersync_flush(...,time,...) -> stepcompress_flush(sc,time) -> queue_flush(sc,time)

        queue_flush调用compress_bisect_add压缩sc->queue数组,使其变成3个32位的数据。

interval:初始脉冲间隔,即第二个脉冲与第一个脉冲相差的时间

count:脉冲数量

add:下一个脉冲的interval增量

        调用add_move将数据打包,并追加到sc->msg_queue上,同时也创建一个节点,记录追加的数据,这个节点挂到sc->history_list上。

add_move(sc,first_clock,move) {
    uint32_t msg[6] = { //打包步进数据
        sc->queue_step_msgtag, sc->oid, sc->print_pos, move->interval, move->count, move->add
    };
    struct queue_message *qm = message_alloc_and_encode(msg, 6);
    ...
    list_add_tail(&qm->node, &sc->msg_queue); //追加到msg_queue上
    sc->last_step_clock = last_clock; //记录最后的脉冲时间
    ...
    list_add_head(&hs->node, &sc->history_list);//保存追加的数据进入历史节点上
}

        调用calc_last_step_print_time函数,将sc->last_step_clock的频数转换成时间sc->last_step_print_time。

serialqueue_send_batch

        在steppersync_flush最后,调用serialqueue_send_batch将sc->msg_queue中取出的信息,发送到MCU上,MCU的command_queue_step函数将解析传入的指令,执行移动。

7.MCU指令接收与脉冲发送

MCU对于上位机的变量挂点

        在klippy:connect事件中,MCU模块执行MCU._connect函数时,_send_config配置下位机,其中就会执行allocate_oids指令对应的下位机函数command_allocate_oids,该函数申请了由oid标记的各块所需的挂点struct oid_s oids,它就两个成员,一个是数据data,一个是该挂点类型type:

void
command_allocate_oids(uint32_t *args)
{
    if (oids)
        shutdown("oids already allocated");
    uint8_t count = args[0];
    oids = alloc_chunk(sizeof(oids[0]) * count);
    oid_count = count;
}
DECL_COMMAND(command_allocate_oids, "allocate_oids count=%c");

        当上位机模块启动时,会向下位机发送指令申请相应的内存空间,其中type对应申请的发起函数,data指向变量空间。

command_config_stepper

        stepper.py中_build_config()函数通过指令config_stepper(对应下位机command_config_stepper函数)向下位机申请相应的数据内存,它调用oid_alloc来实现:

command_config_stepper(uint32_t *args) {
...
    struct stepper *s = oid_alloc(args[0], command_config_stepper, sizeof(*s));
}
...
void *
oid_alloc(uint8_t oid, void *type, uint16_t size)
{
    if (oid >= oid_count || oids[oid].type || is_finalized())
        shutdown("Can't assign oid");
    oids[oid].type = type;
    void *data = alloc_chunk(size);
    oids[oid].data = data;
    return data;
}

        挂点数据部data指向alloc_chunk申请的数据内存,而type则指向原发出申请的函数command_config_stepper,以方便后面识别挂点上哪些数据由同一个函数申请的,因为同一个oid可能由不同的函数申请了相应的变量空间,stepper申请的空间大小就是struct stepper的大小。

        command_config_stepper函数同时对传进来的电机引脚step和dir脚进行初始化。

MCU指令接收command_queue_step

        在stepper.py中,发送指令的命令号,通过stepcompress_fill函数(chelper/stepcompress.c)设置到了sc->queue_step_msgtag上,当调用add_move函数(chelper/stepcompress.c)将命令打包时,这个命令号被放进打包数据的第0号位置:

add_move(...){
    ...
    uint32_t msg[6] = { //打包步进数据
        sc->queue_step_msgtag, sc->oid, sc->print_pos, move->interval, move->count, move->add
    };
    ...
}

        上位机发送移动指令时,MCU端根据命令号找到处理该段数据对应的函数command_queue_step(下位机通过DECL_COMMAND来声明命令号与执行函数对应关系),用它来得处理传来的数据。

        command_queue_step函数调用stepper_oid_lookup找到上面申请的挂点变量空间,申请一个struct stepper_move变量m,将传来的指令解析并提取出interval,add,count到m成员上,并调用move_queue_push将m挂到挂点变量空间struct stepper* s上。

        调用stepper_load_next读取一个挂点,进行下一次移动,sched_add_timer启动定时器,开始脉冲发送。

脉冲发送

        sched_add_timer函数(sched.c)实际是将步进器的定时变量插入或添加到SchedStatus.timer_list中,这可以决定下一次定时中断发生的时间。定时器中断函数ISR(time.c)会不停的调用sched_timer_dispatch,该函数会调用步进事件中断函数stepper_event。

        在stepper_event函数中,不停的执行中断产生脉冲,直到s->count减为0(即该段移动脉冲发送完毕),接着调用stepper_load_next继续调用下一段移动,直到所有挂在挂点变量空间的移动段全部被取出为止,最后标记SF_DONE移动结束。相应的定时变量从SchedStatus.timer_list移出。

        至此,Klipper从G1指令,到驱动电机如何移动全链条疏通完毕。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐