The Signaloid C0-microSD is an FPGA development board in a microSD card form-factor. It boasts an ICE40UP5K FPGA with 128 Mbit (16 MB) SPI flash. Technically thought, the C0-microSD is actually a System on Module (SoM) since the PCB contains a processing core (the FPGA), peripheral interface(s), non-volatile memory, and its own power management circuitry, while still needing to be installed on another PCB of some sort to interface with a host. This is in contrast to standalone development boards that simply able to outside peripherals/computers.
In this case the C0-microSD needs to be installed in an SD card slot of a host PC, or the SD-Dev breakout board Signaloid also offers.
The SD-Dev v1.0 breakout board mainly functions as a method for exposing the C0-microSD board to a host for power measurement/supply, card detection, and programmatic power cycling via either a full sized SD card slot or microSD card slot.
The SD-Dev connects to a host PC via a USB-C port where it acts as a generic USB-to-SD adapter for the host PC to interface with the C0-microSD board.
There are also connectors on the Signaloid SD-Dev breakout board for a Raspberry Pi CM4 so the C0-microSD could alternatively connect to it instead of a host PC as a hot-swappable FPGA module in a standalone configuration.
When the C0-microSD board is installed in one of the SD card slots on the SD-Dev v1.0 breakout board or directly in the SD card slot of a host PC, it appears as a standard SD block device.
The 6 pins of the SD interface on the C0-microSD can be repurposed on custom bitstream designs, along with the 2 on-board LEDs. Then the JTAG interface is directly exposed to the user via 6 test point pads. By default however, the SD interface pins and LEDs are utilized for the way the C0-microSD board loads new bitstreams and other user data onto itself from a host while acting as a standard SD block device.
The Signaloid C0-microSD board utilizes the warm boot capabilities of the iCE40 FPGA to load new bitstreams via block read/write operations then switch between multiple bitstreams as different modes of operation for the C0-microSD.
When first powered on, the C0-microSD loads its "bootloader" bitstream from a 512 KiB offset (address start 0x080000) in the AT25QL128A SPI flash. The bootloader then reads the current configuration version stored in 4 bytes at address start 0x020000. Depending on what those 4 bytes are set to, the C0-microSD either stays in bootloader mode (does not load a new bitstream) or it loads the Signaloid SoC bitstream stored at address start 0x100000.
The C0-microSD uses the two onboard LEDs to indicate which of the first two bitstreams are currently loaded: the red LED illuminates when the bootloader bitstream is loaded, and the green LED illuminates when the Signaloid SoC bitstream stored at 0x100000 is loaded:
If the C0-microSD stays in bootloader mode based on what it reads from the current configuration version memory location, and no SD host is initialized within 3 seconds after performing that read operation, then it loads the bitstream from the user bitstream offset location at address start 0x180000 if there is one.
This warm boot capability of the iCE40 FPGA that allows it to control when it loads different bitstreams like this is such an understated cool feature that I wanted to take the time to explain it a bit by detailing how to properly recreate a bitstream for the C0-microSD containing all three bitstreams for this multi-mode functionality.
While the Signaloid documentation mentions that the C0-microSD uses the warm boot functionality of the iCE40 to functionally achieve this switching between operational modes, it doesn't outline the specific process for those that are not already familiar with it.
iCE40 Boot ProcessBefore getting into the nitty-gritty of the warm boot process, let's take a step back to make sure we understand the boot process of the FPGA first. iCE40 FPGAs are SRAM-based, which means they do not retain their configuration when power is removed. Therefore they need an external non-volatile memory device to store a bitstream on. This non-volatile device most commonly comes in the form of a SPI-based flash memory chip.
When an FPGA powers on, it reads the bitstream out of the flash memory via a SPI bus to program itself with. There also needs to be a way for that bitstream to be loaded into the flash memory in the first place. So another device is added to the SPI bus the FPGA and flash memory share in order to write new bitstreams into the flash.
A majority of the time, the third device added to the SPI bus with the FPGA and flash memory is an FTDI controller. However, it can be any processor with a SPI interface that is capable of transmitting a bitstream file via that SPI interface.
FTDI controllers convert USB packets to various types of serial interfaces like SPI. So the various FPGA development software tools/IDEs use FTDI drivers/controllers to load generated bitstream files onto target FPGA development boards.
On this SPI bus, the flash memory is always a slave because the FPGA is either reading a bitstream file out from it or the FTDI controller is writing one to it. In turn, the FTDI controller is always a master because it's either writing a bitstream file to the flash memory or it's loading the bitstream file directly onto the FPGA (many FPGA development software IDEs have this option to load bitstreams directly onto the target FPGA chip for debugging). Therefore, the FPGA switches between being either a master or a slave on this SPI bus depending on where it is loading a bitstream from.
For iCE40 FPGAs specifically, they determine what mode to be in on the SPI bus (and thus where the bitstream is coming from) by reading the chip select line on its configuration SPI bus once it comes out of a reset state. If the chip select line is set to logic level high/one then the FPGA knows to act as a master and read a bitstream from flash memory. If the chip select line is logic level low/zero then the FPGA knows to act as a slave and wait for the FTDI controller (or whatever external controller) to start sending it a bitstream.
It's also worth noting that the SPI interface on an FPGA its uses to load a bitstream to configure itself with (aka - the configuration SPI interface) cannot be used as a general purpose SPI interface. It's sole function is reading/writing from/to the configuration registers of an FPGA. And that's true of any FPGA chip, not just the iCE40 devices.
This SPI port on FPGAs it what the JTAG (Joint Test Action Group) interface connects to. JTAG is basically a flavor of SPI specifically used in FPGA development for data transfer into non-volatile memory and various debugging operations/protocols. Many the configuration SPI port on many FPGAs use JTAG reference designators, but iCE 40 FPGAs use straight SPI reference designators.
What is Warm Boot?So with the core basics on the iCE40 FPGA boot process clarified, lets take a look at how/where warm boot comes in. The warm boot capability of iCE40 FPGAs has been a cornerstone in many open source development boards for the past 10 years. Going back to 2015 when Project IceStorm was first released, the icemulti
command was included as a tool for packing multiple bitstreams into a single multiboot image file for iCE40 FPGAs to support their dynamic reconfiguration capabilities as outlined in Technical Note TN1248 - iCE40 Programming and Configuration.
Dynamic reconfiguration in FPGAs is defined as the ability of an FPGA to write multiple addressable bitstreams to a flash memory such that any of them can be loaded without the need for any external communication. In other words, the FPGA controls when/which bitstream it loads at any point after being powered on and is able to write those bitstreams to flash memory itself.
iCE40 FPGAs actually have two versions of the multi-image boot process: cold boot and warm boot. The difference between warm and cold boot comes from which mechanism controls the selection of the bitstream to load from flash memory and then forces the FPGA to reconfigure itself.
For cold boot, when the iCE40 boots up from a power-on master reset (a CRESET_B = 0 pulse), it monitors two physical IO pins (CBSEL0 and CBSEL1) to know which of the up to four bitstreams to load from flash memory. In other words, CBSEL0 and CBSEL1 act as a 2-bit address of which bitstream if 4 to load from flash memory.
Ultimately, cold boot just allows for multiple bitstreams to be stored and selected from in flash memory. An external force not in the control of the FPGA logic like the change of a jumper configuration has to be preset to change which bitstream is selected to load, and the FPGA has to be reset or power cycled by an external force to trigger it to reconfigure with that new bitstream.
Warm boot allows for the process of selecting which bitstreams to load from flash memory and the triggering of the FPGA's reconfiguration process to be controlled from user design running in the programmable logic/fabric of the FPGA.
The primitive SB_WARMBOOT replaces the physical pins CBSEL0 and CBSEL1 with signals S0 and S1 accessible to the user HDL. After the user logic selects which bitstream to load from flash on the S1 and S0 inputs of the SB_WARMBOOT primitive, it then sets the BOOT signal input of the primitive to logic level high/one to force the FPGA to reconfigure itself with the new bitstream.
The most well known projects in the open source community that have used the warm/cold boot are the TinyFPGA-Bootloader by Luke Valenty and the foboot for the Fomu by Tim Ansell and Sean Cross. Both projects used a similar idea of a bootloader-type bitstream being loaded upon a reset that contains some sort of interface conversion to SPI for writing user bitstreams to flash memory and then loading them onto the iCE40 FPGA.
The Signaloid C0-microSD follows this same operation of loading a bootloader bitstream then either loading a LiteX-based RISC-V processor SoC bitstream or a custom user bitstream.
Warm Boot on Signaloid C0-microSDWhile the specific HDL source code doesn't appear to be anywhere in the Signaloid repositories (only the compiled.bin bitstream files for the bootloader and default LiteX SoC images are found in the C0-micorSD-Hardware repository) we don't need to see it to know that at least the bootloader bitstream, bootloader.bin
, contains logic that:
1) Instantiates the SB_WARMBOOT primitive.
2) Implements some sort of decision making around setting the S0 & S1 signals to select the target bitstream from flash.
3) After setting S0 & S1, asserts the boot signal to the SB_WARMBOOT primitive to trigger the FPGA to reconfigure itself with the new target bitstream.
I wrote a simple example in Verilog of this core process for performing warm boot operations where a simple state machine monitors a signal to know when a process elsewhere in logic has requested a reconfiguration then it sets the specified slot for the target bitstream, waits one clock cycle to make sure the slot selection propagates through to the SB_WARMBOOT primitive, and finally asserts the boot signal to trigger the FPGA to reconfigure itself.
module ice40_warmboot_logic(
input clk,
input rst_n,
input reprog_fpga,
input [1:0] bitstream_address
);
wire boot;
wire S0, S1;
reg [1:0] state_machine_state;
parameter wait_for_reprog = 2'd00;
parameter set_bitstream_addr = 2'd01;
parameter wait_one_clk_period = 2'd02;
parameter assert_boot = 2'd03;
SB_WARMBOOT wb(
.BOOT(boot),
.S0(S0),
.S1(S1)
);
always @ (posedge clk or negedge rst_n) begin
if (rst_n = '0')
begin
state_machine_state <= wait_for_reprog;
end
else
begin
case(state_machine_state)
wait_for_reprog :
begin
if (reprog_fpga == 1'b1)
state_machine_state <= set_bitstream_addr;
else
state_machine_state <= wait_for_reprog;
end
set_bitstream_addr :
begin
S0 <= bitstream_address[0];
S1 <= bitstream_address[1];
state_machine_state <= wait_one_clk_period;
end
wait_one_clk_period :
begin
state_machine_state <= assert_boot;
end
assert_boot :
begin
boot <= 1'b1;
state_machine_state <= assert_boot;
end
endcase
end
end
endmodule
Now upon first glance, it appears that I made a mistake by leaving my state machine to hang indefinitely in the assert_boot
state, but the FPGA is going to reconfigure itself and load a completely new bitstream so it doesn't really matter because all of the logic in the current bitstream is going to be stopped with the FPGA resets itself to start the reconfiguration process.
I point this out as a reminder to be conscientious of the other processes running in logic when triggering a reconfiguration with the warm boot process, because once the boot signal is asserted to the SB_WARMBOOT primitive there is no way for another process to block/gate the subsequent reconfiguration process. So make sure the logic that triggers that boot signal takes care of gating the rest of the user logic such that it's in a safe state (ie - not performing some sort of critical data transfer).
Once the individual bitstreams (maximum of 4) have been generated with at least one of them containing the SB_WARMBOOT primitive, they need to be compiled into a single multiboot bitstream image using the icemulti
command:
$ icemulti -h
Usage: icemulti [options] input-files
-c
coldboot mode, power on reset image is selected by CBSEL0/CBSEL1
-p0, -p1, -p2, -p3
select power on reset image when not using coldboot mode
-a<n>, -A<n>
align images at 2^<n> bytes. -A also aligns image 0.
-o filename
write output image to file instead of stdout
-v
verbose (repeat to increase verbosity)
As seen in the help info listed using the -h flag, the icemulti
command is pretty straightforward. The -c flag configures the image tell the FPGA to check the external pins CBSEL0/CBSEL1 to follow the cold boot process as described above. In the absence of the -c flag, the FPGA assumes the warm boot process and will look to the user logic within the bitstreams for which bitstream to load and when.
The individual bitstreams are listed then followed by the -o flag with the desired name of the output multiboot bitstream file:
~$ icemulti bitstream0.bin bitstream1.bin bitstream2.bin bitstream3.bin -o wb_bitstream.bin
The -p0, -p1, -p2, or -p3 flag designates one of the input bitstreams as the default to load after a system reset or power-on reset. Otherwise, the iCE40 FPGA will reboot/reset using the last bitstream loaded. Which means each individual bitstream would need to instantiate the SB_WARMBOOT primitive along with the rest of the custom user logic for controlling the selection of which bitstream to load via the primitive's S0 and S1 inputs.
So by specifying one bitstream as the default after a power-cycle/system reset, that allows for only one bitstream to be required to contain the SB_WARMBOOT primitive with corresponding control logic. Therefore this "default" image can act as the bootloader image and is always accessible via a reset or power-cycle.
~$ icemulti -p0 bitstream0.bin bitstream1.bin bitstream2.bin bitstream3.bin -o wb_bitstream.bin
This is why I said at least the bootloader bitstream (bootloader.bin
) in the C0-micorSD-Hardware repository contains the warm boot logic, because according to the flow diagram in the Signaloid the C0-microSD always loads the bootloader bitstream on power up:
But I'd be willing to bet that only the bootloader bitstream contains the warm boot logic because according to their flow diagram, the bootloader reads a separate value it writes to flash to decide which/if to load a different bitstream via the warm boot process.
This is further evidenced when digging into the Python script, C0_microSD_toolkit.py, that Signaloid provides for switching the C0-microSD between modes/bitstreams. All the script does to "switch modes" is write 4 ASCII bytes to flash address offset 0x20000: either "SBLD" for bootloader mode or "SSOC" for SoC mode.
Then per Signaloid's documentation, the C0-microSD has to be power cycled to actually load the new bitstream:
If the prebuild SoC bitstream contained the warm boot HDL logic, then the power cycle wouldn't be necessary. But if each bitstream were able to trigger the warm boot process then logic to keep track of which bitstream is loaded and when to switch potentially becomes much more complex. So it makes sense to contain the warm boot process to the one default bitstream.
This is how the C0-microSD is able to allow for the LiteX SoC bitstream in slot 1 and user bitstreams slot 2 to be flashed without requiring users to configure the warm boot logic into it to be able to switch back. It will always default to the bootloader bitstream in slot 0 of the flash memory.
Finally, because the C0-microSD also uses space in the flash memory to keep track of which bitstream is currently loaded onto the FPGA and which to load next, the image align -A flag also needs to be used with the icemulti
command.
According to Signaloid's documentation, the bootloader bitstream is stored at address offset 0x80000 hex, which is 524288 decimal.
So the bootloader bitstream needs to be aligned by a 524288 byte offset in the flash memory: log base 2 (524288) = 19 (ie- 2^19), therefore the -A<n> flag needs to be -A19 for the multiboot bitstream image on the C0-microSD:
~$ icemulti -p0 -A19 bitstream0.bin bitstream1.bin bitstream2.bin bitstream3.bin -o wb_bitstream.bin
Now while I've talked through the theory of how I think the custom warm boot logic on the C0-microSD works and multiboot bitstream is created (because a pre-built multiboot bitstream isn't available in any of their repos either, just the individual prebuilt bootloader, SoC, and blink LED bitstreams are) let me show how I proved it.
Creating the Multiboot Image for the C0-microSDWhen using the script C0_microSD_toolkit.py to load a new bitstream into the flash memory of the C0-microSD via the SD block device interface, it does a check on the contents of the first 160 bytes in the SPI flash to verify the warm boot is configured properly before performing a write operation to the flash memory:
def verify_warmboot_section(self) -> bool:
warmboot_section = self._read(0, 5*32).hex()
warmboot_section_template = str(
"7eaa997e92000044030800008200000108000000000000000000000000000000"
"7eaa997e92000044030800008200000108000000000000000000000000000000"
"7eaa997e92000044031000008200000108000000000000000000000000000000"
"7eaa997e92000044031800008200000108000000000000000000000000000000"
"7eaa997e92000044030800008200000108000000000000000000000000000000"
)
return warmboot_section == warmboot_section_template
So I took this and reformatted it to match what the output of what the hexdump
command looks like. This told me what the first 10 memory locations of the flash memory should be to compare again my multiboot bitstream I created to validate I had all the right flags set with the icemulti
command:
00000000 7e aa 99 7e 92 00 00 44 03 08 00 00 82 00 00 01
00000010 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000020 7e aa 99 7e 92 00 00 44 03 08 00 00 82 00 00 01
00000030 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000040 7e aa 99 7e 92 00 00 44 03 10 00 00 82 00 00 01
00000050 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000060 7e aa 99 7e 92 00 00 44 03 18 00 00 82 00 00 01
00000070 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000080 7e aa 99 7e 92 00 00 44 03 08 00 00 82 00 00 01
00000090 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Using the aforementioned individual prebuilt bootloader, SoC, and blink LED bitstreams I found in the Signaloid repo here, I created a multi-image bitstream putting the bootloader bitstream in index spot zero (slot 0) by listing it as the first bitstream file in the icemulti
command and setting it as the default bitstream to boot with using the -p0
flag:
~$ icemulti -p0 bootloader.bin signaloid-soc.bin blink.bin -o c0-microsd_wb.bin
Since the SoC bitstream needs to be the next indexed bitstream in the flash memory, followed by the custom user bitstream (I'm using the blink LED bitstream from the default Signaloid repo for this), that's what I have signaloid-soc.bin
and blink.bin
listed in the order I do.
Using the hexdump
command to validate my multi-bitstream image:
~$ hexdump -C c0-microsd.bin > img00.dump
I was able to immediately see where each bitstream starts (denoted by 0x7e 0xaa 0x99 0x7e), but the start address was incorrect as denoted by bytes 10 - 12 which should be 0x08 0x00 0x00 instead of 0x00 0x00 0xa0:
00000000 7e aa 99 7e 92 00 00 44 03 00 00 a0 82 00 00 01
00000010 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000020 7e aa 99 7e 92 00 00 44 03 00 00 a0 82 00 00 01
00000030 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000040 7e aa 99 7e 92 00 00 44 03 01 5b 59 82 00 00 01
00000050 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000060 7e aa 99 7e 92 00 00 44 03 02 f2 68 82 00 00 01
00000070 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000080 7e aa 99 7e 92 00 00 44 03 00 00 a0 82 00 00 01
00000090 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
This is when I realized I forgot to account for the bootloader bitstream address offset of 0x80000 as I previously explained. So adding the -A19 flag I created another multiboot bitstream:
~$ icemulti -p0 -A19 bootloader.bin signaloid-soc.bin blink.bin -o c0-microsd_wb.bin
Then double-checking again with the hexdump
command of the output bitstream, I was able to verify that I now had each of the three bitstreams stored in the appropriate flash memory addresses:
00000000 7e aa 99 7e 92 00 00 44 03 08 00 00 82 00 00 01
00000010 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000020 7e aa 99 7e 92 00 00 44 03 08 00 00 82 00 00 01
00000030 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000040 7e aa 99 7e 92 00 00 44 03 10 00 00 82 00 00 01
00000050 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000060 7e aa 99 7e 92 00 00 44 03 18 00 00 82 00 00 01
00000070 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000080 7e aa 99 7e 92 00 00 44 03 08 00 00 82 00 00 01
00000090 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Now one thing I haven't mentioned yet is the whole reason I ended up down this rabbit hole of reverse engineering the C0-microSD's warm boot configuration and creating my own mutliboot bitstream when Signaloid provides perfectly good documentation for loading new bitstreams via the SD interface.
I immediately jumped in and tried to build my own SoC image for my C0-microSD from the main LiteX repository when I saw it was listed as an available build target. I unfortunately did not see the comment at the end of the script stating loading was not supported in this image.
What that meant was that the logic for communicating to the C0-microSD as an SD block device was not built into this image. So after I flashed my new SoC bitstream into slot 1 and power cycled the board, I had no way to write "SBLD" to address offset 0x20000 in flash to keep the bootloader from always loading this SoC bitstream via the warm boot process when it would check that flash memory location.
No SD interface to communicate to the C0-microSD board also meant I had to connect directly to the SPI configuration port of the iCE40 FPGA (aka the JTAG interface) to upload any new bitstream. This is not the easiest process due to the SPI configuration port being routed to test points on the C0-microSD's PCB.
I opted to pick up a Tigard board to program my C0-microSD versus a Lattice-specific programmer since the Tigard is a FTDI FT2232H-based multi-protocol I can reconfigure and use for other applications.
The Signaloid documentation shows how to connect to the iCE40's configuration SPI port and power the C0-microSD board:
There is also an example of how to use the iceprog
command:
But it doesn't mention that the offset flag, -o, should be used depending on which slot (aka address offset) you want the bitstream to be written to. Because the default offset is 0 bytes, so the command as written in their documentation:
~$ iceprog -I B c0-microsd.bin
will overwrite the warm boot configuration section in the first 512KiB of the AT25QL128A SPI flash, effectively disabling the warm boot functionality so the bootloader will now never load. Only that new bitstream written in the address space starting at 0x000000 will ever load.
So instead of recreating the whole multiboot bitstream with the icemulti
command, I could simply just flash the prebuilt SoC bitstream at the SoC address offset 0x100000 to replace the one I flashed that was missing the SD interface.
Then the iceprog
command with the -o flag and address offset passed in bytes, kilobytes, or Megabytes (0x100000 = 1048576 bytes = 1024 kilobytes = 1 Megabyte) would look like this:
~$ iceprog -I B -o 1M signaloid-soc.bin
~$ iceprog -I B -o 1024k signaloid-soc.bin
~$ iceprog -I B -o 1048576 signaloid-soc.bin
But since I was going to the trouble of connecting to the configuration SPI (JTAG) interface of the C0-microSD, I figured it was worth rebuilding the whole multiboot bitstream since it wasn't that much extra work.
I originally was just going to solder some 26 AWG flying leads to the test pads, but I used the excuse to finally splurge on a set of PCBite probes I'd been eyeing for a while.
It was a bit of a tight fit, but worked super well:
I also got some help setting up the PCBite probes on the C0-microSD in the form of 4 extra paws...
And on an unrelated note - bonus points for the PCBite probes kit for traveling well to Open Sauce this year (along with my Analog Discovery 3 as a 3.3v power supply):
Then to flash my multiboot bitstream onto C0-microSD, I the used the -b flag with the iceprog
command to bulk erase the flash to ensure a fully fresh multiboot bitstream upload (definitely DO NOT use the bulk erase flag -b if just flashing an individual at a specific offset using the -o flag as previously described):
~$ iceprog -b -I B c0-microsd_wb.bin
Finally, I verified that everything worked by testing the various boot conditions: switching between the bootloader mode and SoC mode using the toolkit Python script per the Signaloid documentation:
~$ sudo python3 ./C0_microSD_toolkit.py -t /dev/sdc -s
Then since I had the blink LED in the user bitstream address offset 0x180000, I also made sure the C0-microSD switched to it after 3 seconds by just powering the board by not connecting it to an SD host.
And there you have it: a nice little crash course into the boot process of the iCE40 FPGAs!
Comments