tinygo-org / tinygo

Go compiler for small places. Microcontrollers, WebAssembly (WASM/WASI), and command-line tools. Based on LLVM.
https://tinygo.org
Other
15.3k stars 903 forks source link

Proposal: PWM abstraction changes to match hardware #932

Closed syoder closed 2 years ago

syoder commented 4 years ago

Currently, the machine package implements the methods Configure() and Set(). We need additional functionality in order to control the frequency and possibly other PWM settings per device. But because most devices have multiple channels per PWM, we really need a slightly different abstraction that would allow changing PWM settings separately from setting PWM channel outputs. See #855

Functionality we probably need:

Option A:

PWM could have the following functions:

One variation would be to have configure look like this: Configure(pins []machine.Pin) error and not have a ConfigureChannel().

Option B:

Have separate PWM and PWMChannel structs. PWM could have:

and PWMChannel could have:

It would be somewhat painful to rewrite this for all the currently supported machines, but I think we need to do something in order to support servos and other hardware that uses PWMs. I would be happy to work on this if we can reach consensus. Thoughts?

aykevl commented 4 years ago

Thank you for moving this forward!

I've looked a bit into various PWMs and here is what I found:

So that's it for the hardware. The architecture usually appears to be of a single counter that counts in various ways (up, down, up and down for "phase correct" or "center-aligned" PWM) at a configurable frequency with some prescaler. A PWM usually has about 4 channels that can then be configured in various ways. Usually you can set a value in a channel, which roughly corresponds to the duty cycle (but the actual duty cycle depends on the counter top and timer-dependent bit width).

If you can, please look into the PWM/timer peripherals of other chip families (stm32, NXP, etc) and include them in this thread to make sure the design caters to as much hardware as possible.

Thinking about this, it might make sense to not design a universal "PWM" peripheral but rather a "timer" peripheral that also supports PWM operations, as that is how it is sometimes called in hardware already. And in a way, it is in fact a timer with extra PWM channels.

aykevl commented 4 years ago

Then there is the other end: the users of this API. This is also very important to summarize before doing a PWM design.

Dimmable LEDs are fairly trivial: LEDs do not require precise control over frequency. The main tradeoff for LEDs is color depth (how many brightness levels it supports) versus PWM frequency (should be as high as possible to avoid flicker).

I've also used the TCC in an atsamd51 for my hardware assisted hub75 driver, to enable the display for a very precise time (basically it needs to be low for a very specific time too short to do with normal GPIO operations). It would be nice to have such support included in this API, but because it deviates significantly from the regular use, I'm not sure whether it cleanly fits.

I don't know much about other hardware, https://github.com/tinygo-org/tinygo/issues/855#issuecomment-590008260 has a great summary of hardware requirements.

And as always, simple things should be kept simple while complicated things should remain possible.

Here is what a simple usage of an API could look like:

// in the board-specific file e.g. itsybitsy-m4.go

var channel machine.PWMChannel
func init() {
    pwm := machine.PWM0
    pwm.Configure(machine.PWMConfig{}) // default to some sensible frequency for LEDs
    channel = pwm.Channel(0)
}

// in the main.go file

func main() {
    for i := uint32(0); ; i += 0x1000 {
        channel.Set(i)
        time.Sleep(time.Second/2)
    }
}

That's not as simple as I hope it could be.

Some remarks:

One variation would be to have configure look like this: Configure(pins []machine.Pin) error and not have a ConfigureChannel().

It would be nice to expose extra options, which gets messy quickly when you pass a slice to the Configure call. So in general I'm more in favor of a design like option B, where the PWM (or timer) is separated from the individual channels. This also makes software more composable (as in my example above), as you can pass around individual channels without needing to know how PWMs map to pins or even which channels are on which PWM.

@gwtnz @alankrantas it would be great if you can give some feedback on what specific hardware demands from such a PWM API.

syoder commented 4 years ago

Thinking about this, it might make sense to not design a universal "PWM" peripheral but rather a "timer" peripheral that also supports PWM operations

I believe the nRF52 has both timer and pwm peripherals. Which I guess could both be supported by some common "timer" implementation. But it definitely keeps things confusing! Another option would be a defined interface that both a Timer and PWM adhere to?

And as always, simple things should be kept simple while complicated things should remain possible.

This is the challenge! And it is very hard to do without detailed knowledge of every supported machine - there definitely big gaps in my knowledge.

It would be nice to expose extra options, which gets messy quickly when you pass a slice to the Configure call.

Good point - it probably makes more sense to have a PWMConfig struct with some common set of fields that every machine would define, but individual machines could add additional fields to expose settings unique to them.

alankrantas commented 4 years ago

I probably know much less about hardwares than you all do. It just that buzzers and servos are common in education materials of Arduino, micro:bit and ESP8266 alike. micro:bit's PWM is software-simulated (3 max at a time) and probably so is ESP8266 (MicroPython).

For me, it's totally acceptable that you can only set one frequency across the board at a time (a separate timer you mentioned, is that it?), software PWM or not. That's how micro:bit and ESP8266 behaves. We simply don't mix buzzers and servos. There's not so many occasions to do that anyway.

But it's important that you can change the frequency (in Hz, integer) at any time. I also hope that the API can be as simple as how MicroPython or CircuitPython do it. It's not good for teaching if it gets too complicated.

aykevl commented 4 years ago

micro:bit's PWM is software-simulated (3 max at a time) and probably so is ESP8266 (MicroPython).

The micro:bit (nrf51) doesn't have a dedicated PWM peripheral but it definitely has hardware support. The esp8266 on the other hand doesn't seem to have any hardware support at all so it's hard to get a stable PWM output from it because everything must be done in software.

I totally agree the API should be as simple as possible. But I think limiting to just one frequency would make certain use cases impossible. Also: one PWM instance may only support a limited number of pins so making use of all PWM instances will be necessary.

If you have any concrete API please write it down. It's best to have many ideas to choose from and pick the best parts.

alankrantas commented 4 years ago

Well, it is claimed that micro:bit uses software PWM. Maybe it's a combination of hardware and software (here may have some info). I can't say I really know the answer. But if you tried to output 4th PWM signal in MakeCode the first PWM (on whatever pin) would simply stop.

I don't know what concrete API means; If you means what do I hope it would look like, it's probably like this:

machine.InitPWM()
pwm := machine.PWM{pwmPin}
pwm.Configure(machine.PWMConfig{duty: 32767, freq: 1046})
pwm.Configure(machine.PWMConfig{duty: 32767}) // set to default frequency
pwm.Configure() // set to default frequency, duty = 0?

pwm.Set(32767) // set duty cycle
pwm.SetFreq(1046) // set frequency in Hz (either this pin only or across whole board)
pwm.Deinit() // stop PWM (set duty and freq to 0?)

Also each board may have different available frequency range.

gwtnz commented 4 years ago

As @aykevl and @syoder have mentioned, getting the configs right across different boards / chips is going to be tricky. I'm thinking an interface-based approach (as opposed to a struct-based one) might be the way to go, such that we could define the types Servo and PWM and then have all the low-level details hidden away in their respective machine files, so that individual boards could handle things how they wanted to.

Something like (very similar to the posts above and Arduino etc libs; I'm still pondering how to pass the more specialized data into the generic function calls):

type Servo interface {
        Init(min uint16, max uint16, freq uint32)
        Configure(pin Pin, pulseFreq uint8)
        Set(val uint8)
        Get() uint8
}

type PWM interface {
        Init(min uint32, max uint32, freq uint32)
        Configure(pin Pin)
        Set(val uint32)
        Get() uint32
}

or maybe:

// To set up the timers etc system-wide
type ServoImpl interface {
        Init(min uint16, max uint16, freq uint32)
        Configure(pin Pin, pulseFreq uint8) Servo
}

// To control an individual servo instance associated with a pin
type Servo interface {
        Set(val uint8)
        Get() uint8
}

// To set up the timers etc system-wide
type PWMImpl interface {
        Init(min uint32, max uint32, freq uint32)
        Configure(pin Pin) PWM
}

// To control an individual PWM instance associated with a pin
type PWM interface {
        Set(val uint32)
        Get() uint32
}

IMHO the API would be at the simple level (a la the Arduino library equivalent, easy to get started with), and the implementation would allow for the complex level of managing timer channels, interrupts etc.

This is definitely going to need a bit of thought; looks like the ESP8266 is pretty much out for raw servo control because of that single (known?) timer; it's performance just doesn't look sufficient; even LED work will have to balance frequency with resolution. But something based on the PCA9685 can provide I2C servo control off-chip (though if you have to add another chip, you could just get another microcontroller 😄

On the other hand the stm32 chips are bristling with timers, channels, config options and pins to output them on, and plenty of clock cycles to give the developer choices for frequencies, so almost any conceivable pulse train setup is possible...

It may be that tinygo just plain can't offer features on some of the boards/chips if they can't support it; we probably shouldn't limit the API based on that, but maybe can offer the closest available thing, e.g. no servo control but at least rudimentary PWM on the 8266, similar to the MicroPython lib listed above.

gwtnz commented 4 years ago

periph.io has an interesting example here - there's just a duty cycle and frequency; a servo would be a variation of this with a frequency of 40-50 (Hz), and a duty cycle that ranged between ~2.5% and 12.5% (.5ms-2.5ms, 20ms total for 50Hz freq), though if a servo type was included it would be more intuitive to just have a 0-255 or 0-180 value for the setter but could still call down to the PWM method with an appropriate percentage of the duty cycle:

func (*p Pin) Servo(val uint8) {
   p.PWM(2.5 + val / 25.5, 50)
}

assuming fractions of a percent were available. This would still leave all the quirky timer setup code hidden away nicely.

alankrantas commented 4 years ago

I sometimes use a servo function like this in MicroPython:

def servoWrite(pin, degree):
    actual_degree = int(degree * (122 - 30) / 180 + 30) # convert 0-180 degree to duty cycle (30-122)
    servo = machine.PWM(pin, freq=50, duty=actual_degree)

servo = machine.Pin(5)
servoWrite(servo, 60)

Of course, usually I'll just add a conversion function and directly control the PWM object itself.

This works pretty well for a servo or two, both on ESP8266 and ESP32, and works for several servo variations. Haven't tried more (the power supply is a bigger issue). For more than 3-4 servos it would be indeed sensible to use a PCA9685 board.

Actually, quite a few micro:bit accessories have built-in PCA9685 chips/servo pins and a lithium battery.

gwtnz commented 4 years ago

For LED and servo control, the users would typically vary the duty cycle, and for piezo / buzzer / speaker square-wave sound output, they'd vary the frequency. This does seem like a pretty flexible simple API to use! More complex waveforms and multi-pin or multi-channel things could be left for a future advanced API setup.

gwtnz commented 4 years ago

If I'm reading the ESP8266 MicroPython code correctly, it looks like they're squeezing 1MHz frequency out of the PWM driver / clock, with a 10 bit (1024ish) resolution, and have support for multiple channels, interrupt driven for timing but then software driven for the actual output. I'm not sure how many devices support this frequency (e.g. low end Arduino?) but if we're looking to standardize on some parameters that we'd match the chips/boards to, this seems pretty reasonable for most cases?

syoder commented 4 years ago

So, based on this discussion so far, I think:

@aykevl the example that you gave where you said it was not as simple as you had hoped: is that because it required a board specific file in addition to the main.go file? if so, one option would be to have the pwm.Configure() method provision the peripheral if available, otherwise return an error. Where there are multiple, you would be able to spin up multiple PWM objects and Configure() them. The usage would then look like this:

func main() {
    var pwm = machine.PWM{}
    PWM.Configure(machine.PWMConfig{Frequency: 1000})
    var channel = pwm.Channels(0)
    for i := uint32(0); ; i += 0x1000 {
        channel.Set(i)
        time.Sleep(time.Second/2)
    }
}
aykevl commented 4 years ago

@aykevl the example that you gave where you said it was not as simple as you had hoped: is that because it required a board specific file in addition to the main.go file?

No that was intentional. In my own projects I usually put board-specific configuration in a separate file so that I can easily run the same project on multiple boards. What I was referring to was that I didn't like the amount of configuration that was needed.

I've been thinking a bit more about this issue the last few days and I honestly think the timer/channel distinction is inherent to the hardware, so much that hiding it will do more harm than good. While this might seem easy to use:

machine.LED.SetPWM(frequency, dutyCycle)

or this:

machine.PWM{machine.LED}.Set(frequency, dutyCycle)

It hides the fact that frequency is a property of the timer and not of the pin or the chip as a whole. Even though it introduces a bit more complexity, I think it is better to expose it than to hide it and have a leaky abstraction (or dumbed-down abstraction).

I also considered to maybe add some helper functions for this purpose, but I think they will actually make the API more complex simply by making it bigger.

I'm thinking an interface-based approach (as opposed to a struct-based one) might be the way to go, such that we could define the types Servo and PWM and then have all the low-level details hidden away in their respective machine files, so that individual boards could handle things how they wanted to.

I agree we should have a special driver for servos, but I doubt this driver should be in the machine package. The machine package is intended as a low-level HAL to be used by drivers. For example, most I2C and SPI devices aren't driven directly but through a dedicated driver (in the drivers repo). So what I hope is that we can find a low-level abstraction over the various hardware PWMs that is universal enough for a servo driver.

I will investigate the different PWMs further, maybe there is a way to simplify things in a way that does not prevent more advanced use cases.

Regarding the ESP8266: that "PWM" should be supported but I don't think it should influence the API too much. The chip doesn't really have any PWM to speak of (just a timer) and my intention of the machine package is to support hardware peripherals, not software peripherals.

gwtnz commented 4 years ago

Servo definitely feels to me like an abstraction that could live in e.g. the drivers package; it would just require a PWM or even lower level Timer type thing exposed from probably the machine layer. Even PWM could live in drivers as a particular implementation / use case of a Timer.

aykevl commented 4 years ago

I tried implementing a more flexible PWM, see #1121. While doing that, I noticed a few things:

I've also written some preliminary (untested, incomplete, to-be-changed) documentation: https://github.com/tinygo-org/tinygo-site/pull/87. It's worth checking out locally and running it, because I've included some (incomplete) interactive examples to explain how PWM really works. I put a lot of time in it with the idea that an API can only ever be as good as its documentation. Any feedback would be appreciated.

aykevl commented 4 years ago

The simple case of dimming an LED looks like this:

import "machine"

func main() {
    // Configure the PWM peripheral. The default PWMConfig is fine for many
    // purposes.
    pwm := machine.PWM0
    err := pwm.Configure(machine.PWMConfig{})
    handleError(err, "failed to configure PWM0")

    // Obtain a PWM channel.
    ch, err := pwm.Channel(machine.LED)
    handleError(err, "failed to obtain PWM channel for LED")

    // Set the channel to a 25% duty cycle.
    ch.Set(pwm.Top() / 4)

    // Wait before exiting.
    time.Sleep(time.Hour)
}

It's a bit noisy due to comments and error handling but the basic API isn't all that complex. It looks like this:

func main() {
    machine.PWM0.Configure(machine.PWMConfig{})
    ch, _ := machine.PWM0.Channel(machine.LED)
    ch.Set(pwm.Top() / 4)
}

Here are some more (untested) examples: https://github.com/tinygo-org/tinygo-site/blob/pwm/content/microcontrollers/machine/pwm.md#using-pwm-in-tinygo

The only thing you need to know to use it, is which PWM peripheral you should use for a given pin. This can be added to device documentation. However, if this is still not simple enough, we could add an even simpler API that wraps these:

package machine

func PWMForPin(pin Pin) PWM {
    // some algorithm to pick an appropriate PWM peripheral
}

// SetPinPWM is a helper function to configure and set a pin with a certain PWM value. It is mainly useful for LEDs. The duty cycle is expressed in a range from 0 to 256, where 0 is entirely off and 256 is entirely on.
func SetPinPWM(pin Pin, dutyCycle int) error {
    pwm := PWMForPin(pin)
    err := pwm.Configure(PWMConfig{})
    if err != nil {
        return err
    }
    ch, err := pwm.Channel(pin)
    if err != nil {
        return err
    }
    ch.Set(pwm.Top() * 256 / uint32(dutyCycle))
}

But I'm not sure whether hiding this will help new users. It may be better to teach them how a PWM operates so that if anything goes wrong, they can see why (it's not possible to build a completely flexible API that hides all the details, so I think it's better to make the necessary details explicit).

This API is close to option B described by @syoder, but some things have been shuffled around. Any feedback would be appreciated.

Some more things I learned:

syoder commented 4 years ago

I've just read through this quickly, but I like it! A few questions:

aykevl commented 4 years ago
  • would you expect being able to set the prescaler through a PWMConfig attribute for a specific machine?

  • could this support the use-case where the Period is changed frequently? (for example, to play a tune on a buzzer)

Not exactly, but I think it covers all use cases. Instead of setting a prescaler, the system figures out the smallest prescaler (and thus the largest resolution) that fits the requested frequency. After configuring, you can change the frequency easily using SetPeriod, but it only allows the same or higher frequencies as configured in Configure. Therefore, if you want (for example) to be able to use frequencies in the range of 440Hz to 880Hz, you initially request 440Hz in the Configure call and later update the frequencies to any other frequency (smaller period) in SetPeriod - as long as they are at least 440Hz.

This is a bit higher level, but it allows abstracting away system specific details. Frequencies and prescalers vary massively between chips, even bit widths vary. But I think this API still provides all the power of the PWM while being portable. The reason that this doesn't allow setting a lower frequency after configure, is because that may require disabling the PWM temporarily and thus may disrupt the signal.

I hope I didn't miss something, if so please let me know.

syoder commented 4 years ago

I wonder if the period attribute of PWMConfig should be called minPeriod or something like that? Something to make clear that once you configure it, you can't set it lower. Otherwise I worry people won't catch that and might be confused when their call to SetPeriod doesn't work.

I may have to find a way to get my Circuit Playground Bluefruit off my desk at work so I can help implement the nrf52840 part of this. Actually I think the PWM peripheral is shared between all the nrf52s, at least.

deadprogram commented 4 years ago

Been reading thru this. Really good and thoughtful conversation here. The examples located at https://github.com/tinygo-org/tinygo-site/blob/pwm/content/microcontrollers/machine/pwm.md#using-pwm-in-tinygo are really good at showing the use cases in action.

One small question: some PWM interfaces let you reverse the polarity of the output. Is that something that would be handled in the Configure()?

aykevl commented 3 years ago

I'm working on this again.

I wonder if the period attribute of PWMConfig should be called minPeriod or something like that? Something to make clear that once you configure it, you can't set it lower. Otherwise I worry people won't catch that and might be confused when their call to SetPeriod doesn't work.

I see your point, but I don't think so. The reason is that the period specified there is exactly the period configured in the Configure call, not an upper or lower limit (with the PWM running at something else after Configure). Instead, the SetPeriod should document this limitation.

One small question: some PWM interfaces let you reverse the polarity of the output. Is that something that would be handled in the Configure()?

I think most if not all allow you to do this, per channel (not per timer peripheral). I think this can be implemented with a separate method on the PWMChannel object, for example SetPolarity.

aykevl commented 2 years ago

This has been merged a while ago, so closing.