Submind YouTube summaries
Thumbnail for aarch64 assembly basics | SCHIZONE EP-33

aarch64 assembly basics | SCHIZONE EP-33

Watch on YouTube

Video summary

In this episode of the Skidstone series, the creator introduces a new focus on ARM64 assembly language using the Raspberry Pi Zero as the primary hardware platform. The choice of the Pi Zero is driven by its low cost, energy efficiency, and ease of interfacing with real-world peripherals compared to x86 systems which often require complex USB drivers for similar tasks. Additionally, the creator highlights the convenience of SSH access on Wi-Fi-enabled devices like the Pi, allowing developers to write and run code remotely without needing a local monitor or keyboard setup. While acknowledging that other Raspberry Pi models can be used with minor adjustments to address offsets, this specific tutorial is designed around the constraints and capabilities of the ARM architecture to demonstrate fundamental concepts in assembly programming. The technical discussion begins by contrasting the GNU Assembler (GAS) required for ARM64 against NASM, which was previously used for x86 architectures. A significant hurdle identified is that GAS generates object files containing extensive metadata like symbol tables and literal pools rather than flat binaries, necessitating a custom bash script to extract only the executable code needed for running position-independent programs. The tutorial also delves into the specifics of interacting with GPIO pins through Linux device files, explaining how registers map directly to memory locations where developers can read pin states or write values to control outputs like LEDs and inputs such as switches. To illustrate these concepts practically, three distinct examples are presented: a minimal infinite loop executable consisting solely of an ELF header and a branch instruction that jumps back onto itself; a program that configures GPIO 19 as an output to blink an LED by setting bits in specific function select registers using GP set and clear operations; and finally, a reading example that utilizes the UBFX (unsigned bitfield extract) instruction to isolate individual pin states from a 32-bit register. The final demonstration showcases how this setup can read digital input from a simple copper switch connected to power, printing "on" or "off" based on whether current is flowing through the circuit, thereby proving that assembly code can effectively control physical hardware in real-time environments.
Read the full video transcript
Hey guys, welcome back. Skidstone series back by popular request. Not sure how any of you can stomach these miserable videos, but alas, here we are with a new branch. Actually, billions of new branches. That's a pun, because we're talking about ARM today, AR 64. And for the next coming videos, it'll all be ARM 64, I anticipate. And uh what we're going to do in these videos, well in this video particularly, we're talk about a minimal executable like we did a few years ago with the x86 platform. Um we'll talk about the basics of this ISA. Go into the platform that I'm choosing for this and why I chose it and then do some basic read and write operations to GPIO pins. I have a Pi here. We'll talk about that how that works. So without further ado, yes, I did choose to use this uh Pi Zero. Um, and the reason why is a couple things. One, it is for what it is, it's extremely cheap. It's not, you know, nothing. It I think it runs you like $15 give or take for a Linux capable, small, low power device that you can interact with the real world with. Um, and that's really the main thing is that if you're running like if I wanted to use x86 to interface with real life stuff, um, I would have to get some kind of a USB driver working that, you know, we could somehow inter interface with. Uh, it's not trivial, that's for sure. With this, it's basically trivial. You have a a device you can write to like a file. Um, and you can set pin values high and low very easily. So, this is kind of the I would say the epitome of modern computing and um it's I said it's it's ubiquitous. It's popular, capable. Um there's plenty of support online you can find for these things. And um if you're lucky, some of these PI variants are actually, you know, put in a box with a sticker on it that says made in UK. I'm not sure how true that actually is. It's probably just maybe one solder operation and a sticker that goes on there. Anyway, it's nice to see some western uh manufacturing and I particularly like these uh W versions of the devices just because you can SSH in. Um you don't have to have a monitor. You can plug this thing in anywhere in your house as long as you're on the Wi-Fi network. You can just get in. So I can plug this in far away and uh still write and run code on it. So that's pretty nice. Um so yeah, that's kind of why we're doing this. I think whatever I'm showing you here in this video is either one for one replaceable with any other PI variant. I think the Pi 4 is just a drop in substitution for what we're doing here today. And others if um anything it's just going to be a matter of changing some address offsets which is not very difficult to do. So um don't be turned off by this particular device if that's not what you want to use. There's plenty of other options. So, just some basic differences from x86. Um, you might think that the ISA is different, the actual instructions, but really they're they're not. And in fact, I would say it's basically exactly the same. Um, yeah, the pneumonics may change. Yeah, the which inputs which, you know, you put in there, parameters for the, you know, op code, they might change, sure, but the core syntax is all the same. It's the same kind of idea. Yeah, you have load instructions, store instructions where it's just move, but it's the same concept. It's not very difficult to do. And in reality, remember we in x86, we only had like maybe 20 instructions that we were using for everything, not counting the floating point ones. And so this is the same thing. You're going to use maybe 15 20 instructions for 99% of all your computing needs. So basically same instructions. Um only the biggest problem is that we can't use NASM. NASAM is what we use for x86 and it uh only works with that architecture. Um I I'm not sure if there's another one you could use instead, but I've chosen just to use the the GNU assembler gas um for what we're doing here today, which is a really big problem for a couple reasons. Talk about that in a few minutes. Um, one cool thing you can do is, you know, for the skits x86, I'd often use my phone when I'm traveling to like program stuff using Termox and, you know, trying to do stuff. Obviously, I can't run the code um on my phone because my phone is an ARM processor, but you can actually run this code on your phone. The biggest thing is is there's no GPIO pin, so you can't like turn on LEDs and stuff, but you can still run code and see if it if it actually runs. So that's cool. Um, also what's nice is there's a fixed instruction length. That's one very cool thing. Everything is four bytes. So when you look at the disassembly, everything is just eight characters long. It's very very easy to track. Hey, this is one instruction, that's one instruction, etc. You can kind of very easily look through. Very nice. And also, in my opinion, I'm not very experienced in this stuff, but I would say it probably will outlive uh x86, so there's some future proofing there as well. Okay, so into the main bulk of this uh episode here. So, if you recall in our very first video on x86, we talked about a minimal executable and we wrote this basic elf header that we could use and extend upon to basically tell the uh OS how to load our program into memory and start executing it. That's kind of what it's for. And on the left, you have what we did in the beginning. On the right, you have what we're doing now with AR 64. So, how is it different? Well, the biggest difference you'll see is basically the tool chain is different. And so all of the basic like I don't know pneumonics, whatever you call these things like DB for defined bite, it's just different. Now it's bite. Of course, that's not a huge shift in mindset. So not a big deal. Um but also because we're using the GNU assembler, you have some other things you have to put in there. So divine architecture, etc. And then you you're going to be using like these EQUs instead of these like pre-processor type uh percent things. But again, that's just a very minor point. Um the biggest thing is that some of the numbers will change. So if you look here, the load address that we had used for x86 was like 20,000 hex. Um it didn't really matter what you picked. It seemed to work with most numbers. Um, however, for this ARM processor, I I tried I asked one of my friends to help me and I couldn't get it to work lower than 8,000 hex. So, that's 32 kilobytes, right? I think. So, I guess that makes sense to me. That's like a page or some number for pages. I have no idea to be honest. Um, but next is basically when we use NASM, we were able to output a flat binary. Uh, you know, we used -f.bin. And so we were able to just dump out binaries essentially um with the load address everything was kind of built around that and all the the addresses in there were kind of relative to that which is kind of nice. However using the new assembler it basically wants to dump everything in an object file which is to be expected because that's how the C runtime and stuff works in my understanding but because of that like it's a real convoluted mess. So I've just taken out everything to do with load address and stuff like that just because um we're going to be writing position independent code and it's it's just a huge headache otherwise. So no load load address in the actual um I guess definitions here but we will obviously use it to load in as entry point for the program and we're going to set offsets for everything um relative to that when we get to that in a few minutes. Okay. Um then also I guess the biggest difference that should actually exist that makes sense is there's a a word defined here or we call it a short in the new assembler. Basically it's just a a number that encodes what the processor is. So obviously that's going to change between x64 and ARM 64. So of course that one is different. Besides that not a huge difference in the elf header I would say. Now, more about that uh kind of compilation type process I just mentioned. And so here's the overall outline of what we're going to be doing. We're going to be using this GNU assembler as to compile well to assemble our assembly code into an object temp file. Uh and then let me just show you what what's over here on the right. So basically that operation is this white line. you get this temp object file and this basically again tries to embed our entire binary inside an object file with its own header because it thinks it's a it's you know its own you know elf file and then it it dumps in a bunch of other like um literal pools and symbol tables and stuff in here for no reason. So we basically have to come up with a way to suck out our actual executable from this nonsensical object file and that's going to be our binary that we're going to be able to run. And so basically everything you see here is basically trying to determine how big our executable is by looking into, you know, one of these um bytes here, seeing how big our our thing is, trying to understand what that means. And then this line here basically sucks out um our executable from the binary and then we just uh we run it. That's kind of the gist of this. So not hugely complex. In the future we can implement this in assembly. It's not a huge thing but for the time being a simple bash script should be acceptable. So yeah, like I mentioned before, the real problem is that the new assembler expects everything to be linked eventually with libraries and things and running a linker and all that. And uh because of that, most of this object file is nonsense and we have to get rid of it. And as part of that nonsense is the addresses that uh certain labels are moved to in the literal pool are going to actually be like in this zone here. And so a lot of the addresses if you were just to load them in the conventional ARM way are not going to be valid. You're going to get a seg fault every time because you're going to be looking outside of your actual executable. So, we're going to have to write more so position independent code than you would normally have to on ARM. In my understanding, you most of it is already position independent. Um, but there's a little bit of tweaks we have to do on top of what's generally uh required. Okay. Now, into the basics of GPIO. Again, this is um for the PI very specific, but of course, I'm sure it's very similar for other architectures. if you had something else that was Linux capable and could run and had a a dev GPI mem file. And that's basically how we're going to do this. We have these this file on the processor. So basically if you um flash Raspberry Pi OS to an SD card and plug it in um it it's basically Debian and it will have a device file like this and this basically just kind of maps directly to the register addresses um for file or for GPIO type register operations. So I'll talk about that right here. But basically um all this data you can find in the documentation for the peripherals. So this is the one that I've opened up and it seems to be correct. If your particular architecture that you're using is different, um just look into whatever that is and look at the address offsets. That's what's really important is how far you are from the address of the start of the device file basically. Um but yeah, the process is basically we're going to open this file. We're going to mm mapap 4096 bytes and we're going to read and write basically bits to that file to interact with the GPI opens. So couple steps for this. The first thing we have to do is use this um GPF cell. I guess that stands for function select for general purpose input output pins. So, what those do is basically, I'll get to that in a second, but you can set different functions for different pins. Um, for example, input and output and potentially others as well. Uh, then you have three other basic sets of um four byte entities in this file and they are GP set and GP clear. Those two are things you can write to with this W. Basically, that means you can um set if you basically uh set a one in these different bit locations. You can turn GPIO pins if they're outputs on, set them to high. And with clear, a one would make them low. So, basically, you can just toggle these uh pins on and off as you need. And then this GPE, that's like the the pin level, you can read with this R. Um, you can read the pin level. So, if it's a zero, it'll be low. If it's a one, it'll be high. Very simple. And there's a couple other things here that we'll talk about maybe in the future, but they're not important for today's video. And I should say, I do believe you can actually read the input level on output pins, although there's no reason to do that. Maybe unless you're just curious about something um that you've already set. So, that would be one potential use for that. Okay. So, function select I mentioned before. Um, basically you're setting the the purpose of each pin. And the way it's it works is basically each of those addresses. So, let me go back and say um I hope this is big enough. Basically at this address, so offset zero basically from the file start location. Um, this 32 bits is GPF cell 0. And what that means is it basically defines the functions of GPIO pins 0 through 9. And so basically you can kind of take your pin um integer divide it by 10. So let's say you're of pin pin three, right? Pin 3 is determined by function select three on um GPF cell zero. So the the n here is basically your pin number divided by 10, no remainder. Um, and then you can just look here and see what which pin it actually is. I guess yeah, three would be here because we have zero as well. So, and then pin 19, right? Pin 19 would be on GPF cell one because 19 is between 10 and 19. So, that would that would be basically where this one is, just on the next register up, which is this one here. So, offset four. So you have to basically come up with some logic and I'll talk about that in a second um to figure out which address you should be writing to to set the pin function for each individual pin that you want to interact with. But yeah uh so 000 is input 001 is output and then whatever these ones are they have function they have purposes as well. Uh so that's how that works. Pretty simple. And then yeah you can look every one is three bits. So, um, for example, if you were curious about setting GPIO 9, you'd be writing to bits 27 through 29. If you're looking at, uh, pin 0, it's the first three bits. That's how that works. Now, for setting the pin state, like I mentioned before, all you do is you grab the register. And again, this is um, I should say back here, there were two kind of slots, two 32-bit slots for setting GPIO pins. And the reason why is because this one at offset 1 C, that one is basically for the pin number 0 through 31 and then uh this one is from 32 to whatever is the last one. I think 53 or something. So basically you can modulo your pin number by 32 and that remainder is basically this guy. So if you wanted to turn on pin seven, you would just write a one to the seventh bit of at uh you know whatever this address is. Okay, pretty simple. And then the same thing goes for the GP clear. So if you want to turn off pin 9, you would basically go to the ninth bit on the GP clear address, which is over here. and you would set the one in slot number nine. So, not too hard there either. And then, of course, GPE, same thing, but you're going to be reading, not writing, obviously, because you're looking for the PIN value. You just check if it's a zero or a one. Not sure why they put a typo in the data sheet there. Kind of embarrassing, but yeah, you just read that PIN number. And with that out of the way, that was what, like, oh wow, pretty long. 17 minutes. Let's talk now about the actual code. We won't linger too much on the details because it's very self-explanatory and simple, but we will talk about what we're doing. So, if you if you pull the latest suppository, you'll find um a new directory. So, not a new branch unfortunately, but a new directory which is uh AR64. And in there, I have another set of directories that we're going to populate um today just looking at the basics of ARM assembly. And so, what do we have in here? We have um three examples. One is the middle executable, a basic blank hello world, and then example of GPIO reading. And so we'll first talk about the minimal executable. Um if you remember, I'll just open up the code. Well, let me show you it's running first so you can see how this um functions. It it's looping forever. So the function that we're going to be implementing here is just an infinite branch. So it's four bytes. And which four bytes are they? Um we can check uh the binary basically these are the four bytes. It's so all of this is the elf header and then these are this four bytes is the infinite loop instruction. And so we can talk about how that works. Um that's a branch instruction. So it's a branch offset zero. That's one difference between x86 and ARM 64 is that um the program counter instruction counter whatever it in x86 it pointed to the next instruction in ARM 64 it points to the current instruction and so if you look this is the encoding for an infinite branch it's 00001 that's that's a one in hex and then 01 and then everything else is zero So this immediate encodes where I'm jumping to. Um then this is just 0 1 0 0. And so your encoding is literally 01 04 0 0 0 or something whatever that happens to be. So if you go back. Yeah. Uh so 1 4 and then a bunch of zeros. What did I say? Did I say 01? No. Sorry. It's just it's just one 4 000000. I'm nuts. So yeah, that's how that kind of encodes. Now let's look at how that actually looks in the um the assembly file. So before I mentioned things we've changed basically we've changed a couple things cuz the assembler is different but also this short is different now obviously because we're a different architecture. And uh here is our infinite loop. It's just branch B is for branch dot is for here branch here over and over again. And if you're curious, you could toggle on this. This is SIS exit. This is probably a more appropriate middle executable that just returns a value um 33, but uh who cares about that? So if you look, yeah, this was 124 bytes. That matches up with minimum executable, I believe, from x64, which is to be expected because you have I think it's uh 120 bytes of the ELF and program header combined. I think it's like 64 healther 56 program header and then four bytes for that one instruction. And I mentioned before all instructions in this particular mode are four bytes. Um I'm not sure they have thumb mode on this. You can check but that would be shorter potentially. Okay, that's the first example. Very simple. You can see if you run it, it just uh loops forever. So that's kind of boring. Onto the next example. Example B. This is blank. And for this one, I will run it and I will turn on my camera so you can actually see what I've got here in front of me. Um, yep. So, I've I've got a Pi Zero here. I got this ribbon cable that basically just grabs the pins and expands them here so you can see what we're doing. Um, and and basically, yeah, I've just got a LED running to GPIO 19. Sorry, it's so blurry. And, uh, if I run the code now, let me just, you can see it's blinking. So, uh, not very interesting to look at. I'll turn off the camera really quick and we'll get back to the the program. And so, yeah, how is this working? Let's take a look in the code. So, same old header. Nothing of interest here. Potentially there's one difference and that is I'm loading in. So remember for x86 we had like um a binary and then we had a bunch of like space for the heap and other data that we didn't want to actually store in the binary itself. But when the program loads you want to have space for it. And so basically when the OS loads this program into memory, it loads in a you know code which we have of size code size but then also it loads in a bunch of zeros um after the fact. And why is that? Well, that's because we have to m map open and m map this 4 kilobyt of the device for the GPI openings. And so basically at the end of this file I've determined um that amount of space that we need and just uh mapped it there. So we can access it uh at that that address basically. So how does this work a bit more involved? Let's talk about this more thoroughly. So I mentioned that we're using pin 19 and so I've defined the pin at 19 and then I have some examples of well not examples but some computations of the offsets in the device file for the addresses um of different things. So the function select register the GP set and should be clear as well as the just the value for the bit offset. So what are these things? So remember um there were multiple GPF cell registers or we'll call them uh like four byte locations in that file. And so they were at these different addresses. And so if if you look like some of them were, you know, they're multiples of four bytes offset from the start address of the file. And so the first thing we're doing is basically determining for pin 19, we're actually on, you know, the four byt offset from a start address. If we're on pin 23, we'd be on the 8 byt offset. If we're on pin 4, we'd be on the zero byt offset. That's what this one's doing. GP set and GP clear, the same thing. Just calculating the location of the respective GP set and GP clear locations. And then the bit offset is basically when we were looking at turning on and off this guy, which pin are we actually referring to? So pin 19 is just the 19th pin. And so or no, sorry, that's too easy. That's just a matter of just taking this modulo 32. This is actually looking at the the the function select offset here. So if you look um each of these was three bits and so we just shift basically by that many bits. So that's pretty simple. Um and then the first step is just to open the device file. So we have the file name defined at some location and then there's uh you remember there's some inputs for that SIS call that you have to put in. And so 56 is the file is sorry it's the SIS call number. Um and uh you can open it with certain flags and directory reliveness and um run the sys call and that returns the file descriptor that you're going to be referring to later on. But uh I guess the main difference here from what you would maybe expect in ARM assembly is this nonsense for x1. This is going to basically point to the address in your binary or when your program is loaded into memory where where the address of the file name actually is. And so I've called it GPIOM path. And I think normally you would just load in um the value at this address, which you can do just fine if you use the new assembler because that's going to be a literal pool location. You can do that no problem. Um, however, when you're looking at this kind of stuff using, you know, and and sucking out the executable from these object file, you can't do that anymore because the address is now wrong. So, we have to basically reconstruct that memory location from what we do know. So, we do know it's offset in the the binary and we do know it's offset um when it's loaded into memory. And so we can basically add those two things together. This is just going to be a constant. We def make this at the top of the file. We've you know defined this here. And so that's just going to be hard-coded value in the program. Um and uh so this is basically a position dependent load instruction. And we have to do that because the boomers don't want us to have any fun. Then we have mm map. So then again this takes that file descriptor and it um turns it into a chunk of memory that we can interact with. So it maps a part of our binary of our you know load into memory chunk to the actual device file and uh that's 222 for the sys call number and then you're just plugging in various things including the length of that which again is 4 kilobyt and again we have to load in the um memory location that we're wanting to use as the the map location again with that same weird position independent LDR. are at that point x0 comes out of that and that points to our chunk of memory at the very bottom of this file that now this is basically this. So we have a chunk of memory now that looks just like this and we can interact with it just like this and so we can write to bits anywhere here as long as we know the offset from the start of that mapping. So what do we do? So first thing we do is we have to like I mentioned before set the output of set this the function of this pin to be an output. And so how does that work? Well again we are going to be grabbing the offset location of that particular pin. And so uh again we calculated that beforehand. We have that uh equation here that determines the offset for the function select. And we're just going to snag the four byte quantity in there that gets dropped here in W2. And that's basically the current functions of pins, whatever. I guess it would be 10 through 19 or whatever are now in W2. W2 is the low 32 bits of the 64-bit X2 register on ARM. And basically what we do here is we just um clear out whatever is in there. or it so it's just one. We set those three bits, whatever they were before, who cares? Now they're 001, which is output. And then we drop that back in the same spot where it was. And basically, these five lines have now set pin 19 to be an output pin. Then what I do is I just set a uh set a delay um that we can use later. that's going to be in um X4. We can grab that whenever we need to. And then um determine our pin. Our pin location is just uh pin 19. This should probably be modulo. Thinking about this now, modulo 32, but that's fine. Um and then here's our blink loop. And so all we're doing is we're setting that pin to be one. So you can see W2 basically is the old pin states with now a one at uh the correct location. And all we're doing is as you can see here we're just putting that that pin state in X1 which is the location of the GP set. You can see here. And then we just have a delay. And that delay is whatever this number is for 400 million times. So we're just going to loop through that 400 million time. This is like the the decrement jump nonzero loops that we would have in x86 that just loop with a subtraction of one every time. And then one thing that you have to remember in ARM is that there's a subtraction and then there's a subtraction s and same thing with addition addition s this basically just sets like your control flags and so you can use that at that point to determine how long your loop lasts. Without this s it'll just loop forever basically. So yeah, basically you you set the the 19th bit on this location to be one, then you wait some time, then you clear it in the same exact way, just to a different register location, and then you wait again, and then jump back to the top. And so when we run this, it just loops forever, blinking on and off. like that. Okay. The next thing I want to talk about is the next example which is example C. So, um, this one is a bit different. Let's talk about how this one works before we actually show it. And so, again, header is exactly the same. And I would say this is actually less work because you don't have to write to two different locations. We're just reading from one and so it becomes inherently easier and also we only care about doing it at, you know, obviously one location. And so all this is the same. We open the same file and map it to the same spot. And now all we're doing is we're going to be setting it to be an input pin. And so it's the same thing as before, but even easier because all we have to do is clear those three bits. So whatever they were before, they could be 111. We just have to end that with the three zeros in the right spot, which is what this is. And now it's set to zero. So that's what that does. Now we have a loop that it's going to loop forever and just evaluate the pin state that we picked. And we've picked again, I think pin 19. So yeah, same pin. So all that happens now is we load the pin states at that location. So there's going to be 32 pin states at W2 now. And one of those pin states is the one that we care about. Then there's this instruction here, UBFX. What does it stand for? We can check. I actually have no idea what it stands for. Let's check. You can always look at this. Um I'll put this in the description. um unsigned bitfield extract, which is exactly what we're doing. We're basically extracting one of the pin levels. And so from W2, which is also X2, I'm just taking out one pin, one bit of information at this pin bit. So pin is 19. And so I'm looking at the 19th bit, which is exactly what we did when we talked about how this thing works. Again, you're just looking exactly at the pin number. And so, pin 7 is at offset 7. Pin 12, offset 12. And so, again, we just are extracting the pin number, just one pin. And then, I just being a little bit clever here. I didn't want to have to do compares and stuff. I have to talk about how that works. So, this is just basically kind of a I guess it's like a branchless evaluation of what we're doing. So, I'm taking the pin level. So, it's either going to be zero or one. And then I'm multiplying it by four. So now it's either going to be zero or four. And what that means is that now I can have these two strings in memory off and on. They're both four bytes long. I put an extra space in on, so it's four bytes. And now I can basically print out one or the other. And remember off is that offset off and on is that address off + four. And so I can basically use this value in x2 now which is either zero or four to point to either off or on the strings to print. That's what we're doing here. So just a right cyst call. Um that's number 64 to the standard out descriptor. And I'm just now loading in the address off or on based off what the result was. And then I'm uh printing it out and jumping back to the top. And so yeah, that's how that works. And we can run this. And I probably should have made the input a little bit smaller. Let me do that really quick so you guys can see things in real time. Apologies for this. So if I run this code and say it's off. Well, that's just because the LED is off. I can also plug in this guy here. Let me maybe even zoom out a bit. I've got this. Whenever I can, I try to make things from scratch, obviously. So, this is a little switch basically. Um, it's got some copper contacts in there just to connect electrons or whatever. I don't know, dude. Um, but yeah, we can connect this to power. And whenever I separate these two copper contacts, it uh it says off and I touch them, it says on, off, on, off, on, off, on. Right. So yeah, I mean this is how uh it works. So we can successfully read GPIO states and uh yeah, so that's basic input output with GPIO using the device GPIO MEM on um Raspberry Pi in particular, but it won't be very much different on other variants of that or knockoffs I don't think. And so yeah, I mean this opens a lot of possibilities because now we can interact with sensors with outside peripherals, you know, servos, lights, um, things like that, motors. So we can actually control things in the real world from assembly on a device, which normally wouldn't be possible, especially one that you can just SSH into from anywhere in the world. And I will also mention really quick that Raspberry Pi has a um RPI connect like program that lets you literally like VPN in anywhere in the world to your device as long as there's a connection on the network. And so that makes it extremely powerful to do something like this because you can access it from anywhere that you would want. With that out of the way, thanks for watching. Had some fun. I have some big plans for this so we'll see where this goes. Anyway, thanks for watching.